From dbec2d020fe7381f2323fa9503eb7aeb455c23ae Mon Sep 17 00:00:00 2001 From: Simon Date: Tue, 20 Jan 2026 15:01:35 +0100 Subject: [PATCH 01/13] test: add tdd tests for backend state management of boolean custom filters --- test/mv_web/member_live/index_test.exs | 265 +++++++++++++++++++++++++ 1 file changed, 265 insertions(+) diff --git a/test/mv_web/member_live/index_test.exs b/test/mv_web/member_live/index_test.exs index acca9bf..69d8972 100644 --- a/test/mv_web/member_live/index_test.exs +++ b/test/mv_web/member_live/index_test.exs @@ -656,4 +656,269 @@ 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 "handle_params parses boolean_filter_ 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?boolean_filter_#{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?boolean_filter_#{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?boolean_filter_#{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?boolean_filter_#{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?boolean_filter_#{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?boolean_filter_#{boolean_field1.id}=true&boolean_filter_#{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?boolean_filter_#{boolean_field1.id}=true&boolean_filter_#{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 =~ "boolean_filter_#{boolean_field1.id}=true" + assert path1 =~ "boolean_filter_#{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 boolean_filter params are in URL + path2 = assert_patch(view2) + refute path2 =~ "boolean_filter_" + 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?boolean_filter_#{boolean_field.id}=true") + + # Test sort toggle preserves filter + view + |> element("[data-testid='email']") + |> render_click() + + path1 = assert_patch(view) + assert path1 =~ "boolean_filter_#{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 =~ "boolean_filter_#{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&boolean_filter_#{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 =~ "boolean_filter_#{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?boolean_filter_#{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?boolean_filter_#{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?boolean_filter_#{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 + end end From 37e1553a0206cc752050f292391b3f7c07ad3ccc Mon Sep 17 00:00:00 2001 From: Simon Date: Tue, 20 Jan 2026 15:55:08 +0100 Subject: [PATCH 02/13] feat: add custom boolean field state & URL-Parameter --- lib/mv/constants.ex | 42 ++++++++ lib/mv/membership/import/member_csv.ex | 12 ++- lib/mv_web/live/member_live/index.ex | 133 +++++++++++++++++++++++-- test/mv_web/member_live/index_test.exs | 117 +++++++++++++++++----- 4 files changed, 264 insertions(+), 40 deletions(-) diff --git a/lib/mv/constants.ex b/lib/mv/constants.ex index 73bfcd9..4ef355d 100644 --- a/lib/mv/constants.ex +++ b/lib/mv/constants.ex @@ -19,6 +19,12 @@ defmodule Mv.Constants do @custom_field_prefix "custom_field_" + @boolean_filter_prefix "bf_" + + @max_boolean_filters 50 + + @max_uuid_length 36 + @email_validator_checks [:html_input, :pow] def member_fields, do: @member_fields @@ -33,6 +39,42 @@ defmodule Mv.Constants do """ def custom_field_prefix, do: @custom_field_prefix + @doc """ + Returns the prefix used for boolean custom field filter URL parameters. + + ## Examples + + iex> Mv.Constants.boolean_filter_prefix() + "bf_" + """ + def boolean_filter_prefix, do: @boolean_filter_prefix + + @doc """ + Returns the maximum number of boolean custom field filters allowed per request. + + This limit prevents DoS attacks by restricting the number of filter parameters + that can be processed in a single request. + + ## Examples + + iex> Mv.Constants.max_boolean_filters() + 50 + """ + def max_boolean_filters, do: @max_boolean_filters + + @doc """ + Returns the maximum length of a UUID string (36 characters including hyphens). + + UUIDs in standard format (e.g., "550e8400-e29b-41d4-a716-446655440000") are + exactly 36 characters long. This constant is used for input validation. + + ## Examples + + iex> Mv.Constants.max_uuid_length() + 36 + """ + def max_uuid_length, do: @max_uuid_length + @doc """ Returns the email validator checks used for EctoCommons.EmailValidator. diff --git a/lib/mv/membership/import/member_csv.ex b/lib/mv/membership/import/member_csv.ex index 60cfadf..b4f4318 100644 --- a/lib/mv/membership/import/member_csv.ex +++ b/lib/mv/membership/import/member_csv.ex @@ -303,7 +303,9 @@ defmodule Mv.Membership.Import.MemberCSV do {inserted, failed, errors, _collected_error_count, truncated?} = Enum.reduce(chunk_rows_with_lines, {0, 0, [], 0, false}, fn {line_number, row_map}, - {acc_inserted, acc_failed, acc_errors, acc_error_count, acc_truncated?} -> + {acc_inserted, acc_failed, + acc_errors, acc_error_count, + acc_truncated?} -> current_error_count = existing_error_count + acc_error_count case process_row(row_map, line_number, custom_field_lookup) do @@ -325,7 +327,13 @@ defmodule Mv.Membership.Import.MemberCSV do end end) - {:ok, %{inserted: inserted, failed: failed, errors: Enum.reverse(errors), errors_truncated?: truncated?}} + {:ok, + %{ + inserted: inserted, + failed: failed, + errors: Enum.reverse(errors), + errors_truncated?: truncated? + }} end @doc """ diff --git a/lib/mv_web/live/member_live/index.ex b/lib/mv_web/live/member_live/index.ex index f63407a..1c257bc 100644 --- a/lib/mv_web/live/member_live/index.ex +++ b/lib/mv_web/live/member_live/index.ex @@ -30,6 +30,7 @@ defmodule MvWeb.MemberLive.Index do on_mount {MvWeb.LiveHelpers, :ensure_user_role_loaded} require Ash.Query + require Logger import Ash.Expr import MvWeb.LiveHelpers, only: [current_actor: 1] @@ -43,6 +44,15 @@ defmodule MvWeb.MemberLive.Index do # Prefix used in sort field names for custom fields (e.g., "custom_field_") @custom_field_prefix Mv.Constants.custom_field_prefix() + # Prefix used for boolean custom field filter URL parameters (e.g., "bf_") + @boolean_filter_prefix Mv.Constants.boolean_filter_prefix() + + # Maximum number of boolean custom field filters allowed per request (DoS protection) + @max_boolean_filters Mv.Constants.max_boolean_filters() + + # Maximum length of UUID string (36 characters including hyphens) + @max_uuid_length Mv.Constants.max_uuid_length() + # Member fields that are loaded for the overview # Uses constants from Mv.Constants to ensure consistency # Note: :id is always included for member identification @@ -103,6 +113,7 @@ 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) @@ -220,7 +231,8 @@ defmodule MvWeb.MemberLive.Index do socket.assigns.sort_field, socket.assigns.sort_order, socket.assigns.cycle_status_filter, - new_show_current + new_show_current, + socket.assigns.boolean_custom_field_filters ) new_path = ~p"/members?#{query_params}" @@ -334,7 +346,8 @@ defmodule MvWeb.MemberLive.Index do existing_field_query, existing_sort_query, socket.assigns.cycle_status_filter, - socket.assigns.show_current_cycle + socket.assigns.show_current_cycle, + socket.assigns.boolean_custom_field_filters ) # Set the new path with params @@ -363,7 +376,8 @@ defmodule MvWeb.MemberLive.Index do socket.assigns.sort_field, socket.assigns.sort_order, filter, - socket.assigns.show_current_cycle + socket.assigns.show_current_cycle, + socket.assigns.boolean_custom_field_filters ) new_path = ~p"/members?#{query_params}" @@ -478,6 +492,7 @@ defmodule MvWeb.MemberLive.Index do |> 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) @@ -588,7 +603,8 @@ defmodule MvWeb.MemberLive.Index do field_str, Atom.to_string(order), socket.assigns.cycle_status_filter, - socket.assigns.show_current_cycle + socket.assigns.show_current_cycle, + socket.assigns.boolean_custom_field_filters ) new_path = ~p"/members?#{query_params}" @@ -618,7 +634,8 @@ defmodule MvWeb.MemberLive.Index do socket.assigns.sort_field, socket.assigns.sort_order, socket.assigns.cycle_status_filter, - socket.assigns.show_current_cycle + socket.assigns.show_current_cycle, + socket.assigns.boolean_custom_field_filters ) |> maybe_add_field_selection(socket.assigns[:user_field_selection]) @@ -636,12 +653,14 @@ defmodule MvWeb.MemberLive.Index do # Builds URL query parameters map including all filter/sort state. # Converts cycle_status_filter atom to string for URL. + # Adds boolean custom field filters as bf_=true|false. defp build_query_params( query, sort_field, sort_order, cycle_status_filter, - show_current_cycle + show_current_cycle, + boolean_filters ) do field_str = if is_atom(sort_field) do @@ -672,11 +691,19 @@ defmodule MvWeb.MemberLive.Index do end # Add show_current_cycle if true - if show_current_cycle do - Map.put(base_params, "show_current_cycle", "true") - else - base_params - end + base_params = + if show_current_cycle do + Map.put(base_params, "show_current_cycle", "true") + else + base_params + end + + # Add boolean custom field filters + Enum.reduce(boolean_filters, base_params, fn {custom_field_id, filter_value}, acc -> + param_key = "#{@boolean_filter_prefix}#{custom_field_id}" + param_value = if filter_value == true, do: "true", else: "false" + Map.put(acc, param_key, param_value) + end) end # Loads members from the database with custom field values and applies search/sort/payment filters. @@ -1135,6 +1162,90 @@ 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 + prefix_length = String.length(@boolean_filter_prefix) + + filters = + params + |> Enum.filter(fn {key, _value} -> String.starts_with?(key, @boolean_filter_prefix) end) + |> Enum.reduce(%{}, fn {key, value_str}, acc -> + # 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 + # Validate custom field ID exists and is boolean type + case Ecto.UUID.cast(custom_field_id_str) do + {:ok, _custom_field_id} -> + if Map.has_key?(boolean_custom_fields, custom_field_id_str) do + # Validate filter value + case determine_boolean_filter(value_str) do + nil -> acc + filter_value -> Map.put(acc, custom_field_id_str, filter_value) + end + else + acc + end + + :error -> + acc + end + else + acc + end + end) + + # Security: Limit number of filters to prevent DoS attacks + # Maximum @max_boolean_filters boolean filters allowed per request + filters = + if map_size(filters) > @max_boolean_filters do + Logger.warning( + "Too many boolean filters requested: #{map_size(filters)}, limiting to #{@max_boolean_filters}" + ) + + filters + |> Enum.take(@max_boolean_filters) + |> Enum.into(%{}) + else + filters + end + + assign(socket, :boolean_custom_field_filters, filters) + 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) diff --git a/test/mv_web/member_live/index_test.exs b/test/mv_web/member_live/index_test.exs index 69d8972..7e7249e 100644 --- a/test/mv_web/member_live/index_test.exs +++ b/test/mv_web/member_live/index_test.exs @@ -696,26 +696,26 @@ defmodule MvWeb.MemberLive.IndexTest do assert state.socket.assigns.boolean_custom_field_filters == %{} end - test "handle_params parses boolean_filter_ values correctly", %{conn: conn} do + 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?boolean_filter_#{boolean_field.id}=true") + 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 + assert filters1[boolean_field.id] == true refute filters1[boolean_field.id] == "true" # Test false value {:ok, view2, _html} = - live(conn, "/members?boolean_filter_#{boolean_field.id}=false") + 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 + assert filters2[boolean_field.id] == false refute filters2[boolean_field.id] == "false" end @@ -724,7 +724,7 @@ defmodule MvWeb.MemberLive.IndexTest do fake_id = Ecto.UUID.generate() {:ok, view, _html} = - live(conn, "/members?boolean_filter_#{fake_id}=true") + live(conn, "/members?bf_#{fake_id}=true") state = :sys.get_state(view.pid) filters = state.socket.assigns.boolean_custom_field_filters @@ -739,7 +739,7 @@ defmodule MvWeb.MemberLive.IndexTest do string_field = create_string_custom_field() {:ok, view, _html} = - live(conn, "/members?boolean_filter_#{string_field.id}=true") + live(conn, "/members?bf_#{string_field.id}=true") state = :sys.get_state(view.pid) filters = state.socket.assigns.boolean_custom_field_filters @@ -758,7 +758,7 @@ defmodule MvWeb.MemberLive.IndexTest do for invalid_value <- invalid_values do {:ok, view, _html} = - live(conn, "/members?boolean_filter_#{boolean_field.id}=#{invalid_value}") + live(conn, "/members?bf_#{boolean_field.id}=#{invalid_value}") state = :sys.get_state(view.pid) filters = state.socket.assigns.boolean_custom_field_filters @@ -777,14 +777,14 @@ defmodule MvWeb.MemberLive.IndexTest do {:ok, view, _html} = live( conn, - "/members?boolean_filter_#{boolean_field1.id}=true&boolean_filter_#{boolean_field2.id}=false" + "/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 filters[boolean_field1.id] == true + assert filters[boolean_field2.id] == false assert map_size(filters) == 2 end @@ -799,7 +799,7 @@ defmodule MvWeb.MemberLive.IndexTest do {:ok, view1, _html} = live( conn, - "/members?boolean_filter_#{boolean_field1.id}=true&boolean_filter_#{boolean_field2.id}=false" + "/members?bf_#{boolean_field1.id}=true&bf_#{boolean_field2.id}=false" ) # Trigger a search to see if filters are preserved in URL @@ -809,8 +809,8 @@ defmodule MvWeb.MemberLive.IndexTest do # Check that the patch includes boolean filters path1 = assert_patch(view1) - assert path1 =~ "boolean_filter_#{boolean_field1.id}=true" - assert path1 =~ "boolean_filter_#{boolean_field2.id}=false" + 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") @@ -820,9 +820,9 @@ defmodule MvWeb.MemberLive.IndexTest do |> element("[data-testid='search-input']") |> render_change(%{value: "test"}) - # Check that no boolean_filter params are in URL + # Check that no bf_ params are in URL path2 = assert_patch(view2) - refute path2 =~ "boolean_filter_" + refute path2 =~ "bf_" end test "boolean filters are preserved during navigation actions", %{conn: conn} do @@ -830,7 +830,7 @@ defmodule MvWeb.MemberLive.IndexTest do boolean_field = create_boolean_custom_field() {:ok, view, _html} = - live(conn, "/members?boolean_filter_#{boolean_field.id}=true") + live(conn, "/members?bf_#{boolean_field.id}=true") # Test sort toggle preserves filter view @@ -838,7 +838,7 @@ defmodule MvWeb.MemberLive.IndexTest do |> render_click() path1 = assert_patch(view) - assert path1 =~ "boolean_filter_#{boolean_field.id}=true" + assert path1 =~ "bf_#{boolean_field.id}=true" # Test search change preserves filter view @@ -846,7 +846,7 @@ defmodule MvWeb.MemberLive.IndexTest do |> render_change(%{value: "test"}) path2 = assert_patch(view) - assert path2 =~ "boolean_filter_#{boolean_field.id}=true" + assert path2 =~ "bf_#{boolean_field.id}=true" end test "boolean filters work together with cycle_status_filter", %{conn: conn} do @@ -856,14 +856,14 @@ defmodule MvWeb.MemberLive.IndexTest do {:ok, view, _html} = live( conn, - "/members?cycle_status_filter=paid&boolean_filter_#{boolean_field.id}=true" + "/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 filters[boolean_field.id] == true assert state.socket.assigns.cycle_status_filter == :paid # Both should be in URL when triggering search @@ -873,7 +873,7 @@ defmodule MvWeb.MemberLive.IndexTest do path = assert_patch(view) assert path =~ "cycle_status_filter=paid" - assert path =~ "boolean_filter_#{boolean_field.id}=true" + assert path =~ "bf_#{boolean_field.id}=true" end test "handle_params removes filter when custom field is deleted", %{conn: conn} do @@ -882,18 +882,18 @@ defmodule MvWeb.MemberLive.IndexTest do # Set up filter via URL {:ok, view, _html} = - live(conn, "/members?boolean_filter_#{boolean_field.id}=true") + 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 + 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?boolean_filter_#{boolean_field.id}=true") + 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 @@ -911,14 +911,77 @@ defmodule MvWeb.MemberLive.IndexTest do encoded_id = URI.encode(boolean_field.id) {:ok, view, _html} = - live(conn, "/members?boolean_filter_#{encoded_id}=true") + 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 + 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 = + boolean_fields + |> Enum.map(fn cf -> "bf_#{cf.id}=true" end) + |> Enum.join("&") + + {: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 end end From d65da2f4983a223c01f60cc7397e8d80aaea6ff3 Mon Sep 17 00:00:00 2001 From: Simon Date: Tue, 20 Jan 2026 17:03:58 +0100 Subject: [PATCH 03/13] test: add tdd tests for custom boolean field filter logic --- test/mv_web/member_live/index_test.exs | 512 ++++++++++++++++++++++--- 1 file changed, 469 insertions(+), 43 deletions(-) diff --git a/test/mv_web/member_live/index_test.exs b/test/mv_web/member_live/index_test.exs index 7e7249e..31aba05 100644 --- a/test/mv_web/member_live/index_test.exs +++ b/test/mv_web/member_live/index_test.exs @@ -3,6 +3,49 @@ defmodule MvWeb.MemberLive.IndexTest do import Phoenix.LiveViewTest require Ash.Query + alias Mv.MembershipFees.MembershipFeeType + alias Mv.MembershipFees.MembershipFeeCycle + + # Helper to create a membership fee type (shared across all tests) + defp create_fee_type(attrs) 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!() + end + + # Helper to create a cycle (shared across all tests) + defp create_cycle(member, fee_type, attrs) do + # Delete any auto-generated cycles first to avoid conflicts + existing_cycles = + MembershipFeeCycle + |> Ash.Query.filter(member_id == ^member.id) + |> Ash.read!() + + Enum.each(existing_cycles, fn cycle -> Ash.destroy!(cycle) 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!() + 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") @@ -457,24 +500,6 @@ defmodule MvWeb.MemberLive.IndexTest do end describe "cycle status filter" do - alias Mv.MembershipFees.MembershipFeeType - alias Mv.MembershipFees.MembershipFeeCycle - - # Helper to create a membership fee type - defp create_fee_type(attrs) 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!() - end - # Helper to create a member defp create_member(attrs) do default_attrs = %{ @@ -490,31 +515,6 @@ defmodule MvWeb.MemberLive.IndexTest do |> Ash.create!() end - # Helper to create a cycle - defp create_cycle(member, fee_type, attrs) do - # Delete any auto-generated cycles first to avoid conflicts - existing_cycles = - MembershipFeeCycle - |> Ash.Query.filter(member_id == ^member.id) - |> Ash.read!() - - Enum.each(existing_cycles, fn cycle -> Ash.destroy!(cycle) 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!() - end - test "filter shows only members with paid status in last cycle", %{conn: conn} do conn = conn_with_oidc_user(conn) fee_type = create_fee_type(%{interval: :yearly}) @@ -983,5 +983,431 @@ defmodule MvWeb.MemberLive.IndexTest do 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) 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() + + {: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() + + # Reload member with custom field values + member + |> Ash.load!(:custom_field_values) + end + + # Tests for get_boolean_custom_field_value/2 + test "get_boolean_custom_field_value extracts true from Ash.Union format", %{conn: _conn} do + boolean_field = create_boolean_custom_field() + member = create_member_with_boolean_value(%{}, boolean_field, true) + + # 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 + boolean_field = create_boolean_custom_field() + member = create_member_with_boolean_value(%{}, boolean_field, false) + + 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 type and value keys", %{conn: _conn} do + 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() + + # Create CustomFieldValue with map format + {:ok, _cfv} = + Mv.Membership.CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member.id, + custom_field_id: boolean_field.id, + value: %{"type" => "boolean", "value" => true} + }) + |> Ash.create() + + # Reload member with custom field values + member = member |> Ash.load!(:custom_field_values) + + 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 + 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() + + # Member has no custom field value for this field + member = member |> Ash.load!(:custom_field_values) + + 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 + 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() + + # 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() + + member = member |> Ash.load!(:custom_field_values) + + 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 + 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() + + # 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() + + member = member |> Ash.load!(:custom_field_values) + + # 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 + boolean_field = create_boolean_custom_field() + + member_with_true = create_member_with_boolean_value(%{first_name: "TrueMember"}, boolean_field, true) + member_with_false = create_member_with_boolean_value(%{first_name: "FalseMember"}, boolean_field, false) + {: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() + + 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} + + result = MvWeb.MemberLive.Index.apply_boolean_custom_field_filters(members, filters) + + 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 + boolean_field = create_boolean_custom_field() + + member_with_true = create_member_with_boolean_value(%{first_name: "TrueMember"}, boolean_field, true) + member_with_false = create_member_with_boolean_value(%{first_name: "FalseMember"}, boolean_field, false) + {: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() + + 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) => false} + + result = MvWeb.MemberLive.Index.apply_boolean_custom_field_filters(members, filters) + + 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 + boolean_field = create_boolean_custom_field() + + member1 = create_member_with_boolean_value(%{first_name: "Member1"}, boolean_field, true) + member2 = create_member_with_boolean_value(%{first_name: "Member2"}, boolean_field, false) + + members = [member1, member2] + filters = %{} + + result = MvWeb.MemberLive.Index.apply_boolean_custom_field_filters(members, filters) + + 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 + 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() + + {: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() + + {: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() + + member_both_true = member_both_true |> Ash.load!(:custom_field_values) + + # 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() + + {: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() + + {: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() + + member_mixed = member_mixed |> Ash.load!(:custom_field_values) + + members = [member_both_true, member_mixed] + filters = %{ + to_string(boolean_field1.id) => true, + to_string(boolean_field2.id) => true + } + + result = MvWeb.MemberLive.Index.apply_boolean_custom_field_filters(members, filters) + + # 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 + boolean_field = create_boolean_custom_field() + fake_id = Ecto.UUID.generate() + + member = create_member_with_boolean_value(%{first_name: "Member"}, boolean_field, true) + + members = [member] + filters = %{fake_id => true} + + result = MvWeb.MemberLive.Index.apply_boolean_custom_field_filters(members, filters) + + # 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 + 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) + member_with_false = create_member_with_boolean_value(%{first_name: "FalseMember"}, boolean_field, false) + {: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() + + # 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 + conn = conn_with_oidc_user(conn) + boolean_field = create_boolean_custom_field() + fee_type = create_fee_type(%{interval: :yearly}) + 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() + + {: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() + + create_cycle(member_paid_true, fee_type, %{cycle_start: last_year_start, status: :paid}) + + # 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() + + {: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() + + create_cycle(member_unpaid_true, fee_type, %{cycle_start: last_year_start, status: :unpaid}) + + # 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 + 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) + member_with_false = create_member_with_boolean_value(%{first_name: "FalseMember"}, boolean_field, false) + + # 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 end end From ff8b29cffe697fec9f4bfb9cdc8ba6ca49746de7 Mon Sep 17 00:00:00 2001 From: Simon Date: Tue, 20 Jan 2026 18:01:25 +0100 Subject: [PATCH 04/13] feat: implement filter logic for boolean ustom fields --- lib/mv_web/live/member_live/index.ex | 169 +++++- test/mv_web/member_live/index_test.exs | 710 ++++++++++++++----------- 2 files changed, 558 insertions(+), 321 deletions(-) diff --git a/lib/mv_web/live/member_live/index.ex b/lib/mv_web/live/member_live/index.ex index 1c257bc..6eba629 100644 --- a/lib/mv_web/live/member_live/index.ex +++ b/lib/mv_web/live/member_live/index.ex @@ -768,6 +768,14 @@ defmodule MvWeb.MemberLive.Index do socket.assigns.show_current_cycle ) + # Apply boolean custom field filters if set + members = + apply_boolean_custom_field_filters( + members, + socket.assigns.boolean_custom_field_filters, + socket.assigns.all_custom_fields + ) + # Sort in memory if needed (for custom fields) members = if sort_after_load do @@ -1279,7 +1287,166 @@ defmodule MvWeb.MemberLive.Index do values when is_list(values) -> Enum.find(values, fn cfv -> cfv.custom_field_id == custom_field.id or - (cfv.custom_field && cfv.custom_field.id == custom_field.id) + (match?(%{custom_field: %{id: _}}, cfv) && cfv.custom_field.id == custom_field.id) + end) + + _ -> + nil + end + end + + # Extracts the boolean value from a member's custom field value. + # + # Handles different value formats: + # - `%Ash.Union{value: value, type: :boolean}` - Extracts value from union + # - Map format with `"type"` and `"value"` keys - Extracts from map + # - Map format with `"_union_type"` and `"_union_value"` keys - Extracts from map + # + # Returns: + # - `true` if the custom field value is boolean true + # - `false` if the custom field value is boolean false + # - `nil` if no custom field value exists, value is nil, or value is not boolean + # + # Examples: + # get_boolean_custom_field_value(member, boolean_field) -> true + # get_boolean_custom_field_value(member, non_existent_field) -> nil + def get_boolean_custom_field_value(member, custom_field) do + case get_custom_field_value(member, custom_field) do + nil -> + nil + + cfv -> + extract_boolean_value(cfv.value) + end + end + + # Extracts boolean value from custom field value, handling different formats. + # + # Handles: + # - `%Ash.Union{value: value, type: :boolean}` - Union struct format + # - Map with `"type"` and `"value"` keys - JSONB map format + # - Map with `"_union_type"` and `"_union_value"` keys - Alternative map format + # - Direct boolean value - Primitive boolean + # + # Returns `true`, `false`, or `nil`. + defp extract_boolean_value(%Ash.Union{value: value, type: :boolean}) do + extract_boolean_value(value) + end + + defp extract_boolean_value(value) when is_map(value) do + # Handle map format from JSONB + type = Map.get(value, "type") || Map.get(value, "_union_type") + val = Map.get(value, "value") || Map.get(value, "_union_value") + + if type == "boolean" or type == :boolean do + extract_boolean_value(val) + else + nil + end + end + + defp extract_boolean_value(value) when is_boolean(value), do: value + defp extract_boolean_value(nil), do: nil + defp extract_boolean_value(_), do: nil + + # Applies boolean custom field filters to a list of members. + # + # Filters members based on boolean custom field values. Only members that match + # ALL active filters (AND logic) are returned. + # + # Parameters: + # - `members` - List of Member resources with loaded custom_field_values + # - `filters` - Map of `%{custom_field_id_string => true | false}` + # - `all_custom_fields` - List of all CustomField resources (for validation) + # + # Returns: + # - Filtered list of members that match all active filters + # - All members if filters map is empty + # - Filters with non-existent custom field IDs are ignored + # + # Examples: + # apply_boolean_custom_field_filters(members, %{"uuid-123" => true}, all_custom_fields) -> [member1, ...] + # apply_boolean_custom_field_filters(members, %{}, all_custom_fields) -> members + def apply_boolean_custom_field_filters(members, filters, _all_custom_fields) + when map_size(filters) == 0 do + members + end + + def apply_boolean_custom_field_filters(members, filters, all_custom_fields) do + # Build a map of valid boolean custom field IDs (as strings) for quick lookup + valid_custom_field_ids = + all_custom_fields + |> Enum.filter(&(&1.value_type == :boolean)) + |> MapSet.new(fn cf -> to_string(cf.id) end) + + # Filter out invalid custom field IDs from filters + valid_filters = + Enum.filter(filters, fn {custom_field_id_str, _value} -> + MapSet.member?(valid_custom_field_ids, custom_field_id_str) + end) + |> Enum.into(%{}) + + # If no valid filters remain, return all members + if map_size(valid_filters) == 0 do + members + else + Enum.filter(members, fn member -> + matches_all_filters?(member, valid_filters) + end) + end + end + + # Checks if a member matches all active boolean filters. + # + # A member matches a filter if: + # - The filter value is `true` and the member's custom field value is `true` + # - The filter value is `false` and the member's custom field value is `false` + # + # Members without a custom field value or with `nil` value do not match any filter. + # + # Returns `true` if all filters match, `false` otherwise. + defp matches_all_filters?(member, filters) do + Enum.all?(filters, fn {custom_field_id_str, filter_value} -> + matches_filter?(member, custom_field_id_str, filter_value) + end) + end + + # Checks if a member matches a specific boolean filter. + # + # Finds the custom field value by ID and checks if the member's boolean value + # matches the filter value. + # + # Returns: + # - `true` if the member's boolean value matches the filter value + # - `false` if no custom field value exists (member is filtered out) + # - `false` if value is nil or values don't match + defp matches_filter?(member, custom_field_id_str, filter_value) do + case find_custom_field_value_by_id(member, custom_field_id_str) do + nil -> + false + + cfv -> + boolean_value = extract_boolean_value(cfv.value) + boolean_value == filter_value + end + end + + # Finds a custom field value by custom field ID string. + # + # Searches through the member's custom_field_values to find one matching + # the given custom field ID. + # + # Returns the CustomFieldValue or nil. + defp find_custom_field_value_by_id(member, custom_field_id_str) do + case member.custom_field_values do + nil -> + nil + + values when is_list(values) -> + Enum.find(values, fn cfv -> + to_string(cfv.custom_field_id) == custom_field_id_str or + (match?(%{custom_field: %{id: _}}, cfv) && + to_string(cfv.custom_field.id) == custom_field_id_str) end) _ -> diff --git a/test/mv_web/member_live/index_test.exs b/test/mv_web/member_live/index_test.exs index 31aba05..e5330da 100644 --- a/test/mv_web/member_live/index_test.exs +++ b/test/mv_web/member_live/index_test.exs @@ -985,14 +985,18 @@ defmodule MvWeb.MemberLive.IndexTest do end # Helper to create a member with a boolean custom field value - defp create_member_with_boolean_value(member_attrs \\ %{}, custom_field, value) do + defp create_member_with_boolean_value(member_attrs, custom_field, value) 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.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() {:ok, _cfv} = @@ -1011,403 +1015,469 @@ defmodule MvWeb.MemberLive.IndexTest do # Tests for get_boolean_custom_field_value/2 test "get_boolean_custom_field_value extracts true from Ash.Union format", %{conn: _conn} do - boolean_field = create_boolean_custom_field() - member = create_member_with_boolean_value(%{}, boolean_field, true) + boolean_field = create_boolean_custom_field() + member = create_member_with_boolean_value(%{}, boolean_field, true) - # Test the function (will fail until implemented) - result = MvWeb.MemberLive.Index.get_boolean_custom_field_value(member, boolean_field) + # Test the function (will fail until implemented) + result = MvWeb.MemberLive.Index.get_boolean_custom_field_value(member, boolean_field) - assert result == true - end + assert result == true + end test "get_boolean_custom_field_value extracts false from Ash.Union format", %{conn: _conn} do - boolean_field = create_boolean_custom_field() - member = create_member_with_boolean_value(%{}, boolean_field, false) + boolean_field = create_boolean_custom_field() + member = create_member_with_boolean_value(%{}, boolean_field, false) - result = MvWeb.MemberLive.Index.get_boolean_custom_field_value(member, boolean_field) + result = MvWeb.MemberLive.Index.get_boolean_custom_field_value(member, boolean_field) - assert result == false - end + assert result == false + end - test "get_boolean_custom_field_value extracts true from map format with type and value keys", %{conn: _conn} do - 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() + test "get_boolean_custom_field_value extracts true from map format with _union_type and _union_value keys", + %{conn: _conn} do + boolean_field = create_boolean_custom_field() - # Create CustomFieldValue with map format - {:ok, _cfv} = - Mv.Membership.CustomFieldValue - |> Ash.Changeset.for_create(:create, %{ - member_id: member.id, - custom_field_id: boolean_field.id, - value: %{"type" => "boolean", "value" => true} - }) - |> Ash.create() + {: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() - # Reload member with custom field values - member = member |> Ash.load!(:custom_field_values) + # 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() - result = MvWeb.MemberLive.Index.get_boolean_custom_field_value(member, boolean_field) + # Reload member with custom field values + member = member |> Ash.load!(:custom_field_values) - assert result == true - end + result = MvWeb.MemberLive.Index.get_boolean_custom_field_value(member, boolean_field) - test "get_boolean_custom_field_value returns nil when no CustomFieldValue exists", %{conn: _conn} do - 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() + assert result == true + end - # Member has no custom field value for this field - member = member |> Ash.load!(:custom_field_values) + test "get_boolean_custom_field_value returns nil when no CustomFieldValue exists", %{ + conn: _conn + } do + boolean_field = create_boolean_custom_field() - result = MvWeb.MemberLive.Index.get_boolean_custom_field_value(member, boolean_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() - assert result == nil - end + # Member has no custom field value for this field + member = member |> Ash.load!(:custom_field_values) - test "get_boolean_custom_field_value returns nil when CustomFieldValue has nil value", %{conn: _conn} do - 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() + result = MvWeb.MemberLive.Index.get_boolean_custom_field_value(member, boolean_field) - # 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() + assert result == nil + end - member = member |> Ash.load!(:custom_field_values) + test "get_boolean_custom_field_value returns nil when CustomFieldValue has nil value", %{ + conn: _conn + } do + boolean_field = create_boolean_custom_field() - result = MvWeb.MemberLive.Index.get_boolean_custom_field_value(member, boolean_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() - assert result == nil - end + # 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() - test "get_boolean_custom_field_value returns nil for non-boolean CustomFieldValue", %{conn: _conn} do - string_field = create_string_custom_field() - boolean_field = create_boolean_custom_field() + member = member |> Ash.load!(:custom_field_values) - {: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() + result = MvWeb.MemberLive.Index.get_boolean_custom_field_value(member, boolean_field) - # 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() + assert result == nil + end - member = member |> Ash.load!(:custom_field_values) + test "get_boolean_custom_field_value returns nil for non-boolean CustomFieldValue", %{ + conn: _conn + } do + string_field = create_string_custom_field() + boolean_field = create_boolean_custom_field() - # Try to get boolean value from string field - should return nil - result = MvWeb.MemberLive.Index.get_boolean_custom_field_value(member, boolean_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() - assert result == nil - end + # 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() + + member = member |> Ash.load!(:custom_field_values) + + # 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 - boolean_field = create_boolean_custom_field() + test "apply_boolean_custom_field_filters filters members with true value and excludes false/without values", + %{conn: _conn} do + boolean_field = create_boolean_custom_field() - member_with_true = create_member_with_boolean_value(%{first_name: "TrueMember"}, boolean_field, true) - member_with_false = create_member_with_boolean_value(%{first_name: "FalseMember"}, boolean_field, false) - {: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() + member_with_true = + create_member_with_boolean_value(%{first_name: "TrueMember"}, boolean_field, true) - member_without_value = member_without_value |> Ash.load!(:custom_field_values) + member_with_false = + create_member_with_boolean_value(%{first_name: "FalseMember"}, boolean_field, false) - members = [member_with_true, member_with_false, member_without_value] - filters = %{to_string(boolean_field.id) => true} + {: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() - result = MvWeb.MemberLive.Index.apply_boolean_custom_field_filters(members, filters) + member_without_value = member_without_value |> Ash.load!(:custom_field_values) - 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 + 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!() - test "apply_boolean_custom_field_filters filters members with false value and excludes true/without values", %{conn: _conn} do - boolean_field = create_boolean_custom_field() + result = + MvWeb.MemberLive.Index.apply_boolean_custom_field_filters( + members, + filters, + all_custom_fields + ) - member_with_true = create_member_with_boolean_value(%{first_name: "TrueMember"}, boolean_field, true) - member_with_false = create_member_with_boolean_value(%{first_name: "FalseMember"}, boolean_field, false) - {: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() + 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 - member_without_value = member_without_value |> Ash.load!(:custom_field_values) + test "apply_boolean_custom_field_filters filters members with false value and excludes true/without values", + %{conn: _conn} do + boolean_field = create_boolean_custom_field() - members = [member_with_true, member_with_false, member_without_value] - filters = %{to_string(boolean_field.id) => false} + member_with_true = + create_member_with_boolean_value(%{first_name: "TrueMember"}, boolean_field, true) - result = MvWeb.MemberLive.Index.apply_boolean_custom_field_filters(members, filters) + member_with_false = + create_member_with_boolean_value(%{first_name: "FalseMember"}, boolean_field, false) - 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 + {: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() - test "apply_boolean_custom_field_filters returns all members when filter map is empty", %{conn: _conn} do - boolean_field = create_boolean_custom_field() + member_without_value = member_without_value |> Ash.load!(:custom_field_values) - member1 = create_member_with_boolean_value(%{first_name: "Member1"}, boolean_field, true) - member2 = create_member_with_boolean_value(%{first_name: "Member2"}, boolean_field, false) + 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!() - members = [member1, member2] - filters = %{} + result = + MvWeb.MemberLive.Index.apply_boolean_custom_field_filters( + members, + filters, + all_custom_fields + ) - result = MvWeb.MemberLive.Index.apply_boolean_custom_field_filters(members, filters) + 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 - 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 returns all members when filter map is empty", %{ + conn: _conn + } do + boolean_field = create_boolean_custom_field() + member1 = create_member_with_boolean_value(%{first_name: "Member1"}, boolean_field, true) + member2 = create_member_with_boolean_value(%{first_name: "Member2"}, boolean_field, false) - test "apply_boolean_custom_field_filters applies multiple filters with AND logic", %{conn: _conn} do - boolean_field1 = create_boolean_custom_field(%{name: "Field1"}) - boolean_field2 = create_boolean_custom_field(%{name: "Field2"}) + members = [member1, member2] + filters = %{} + all_custom_fields = Mv.Membership.CustomField |> Ash.read!() - # 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() + result = + MvWeb.MemberLive.Index.apply_boolean_custom_field_filters( + members, + filters, + all_custom_fields + ) - {: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() + assert length(result) == 2 - {: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() + assert Enum.all?([member1.id, member2.id], fn id -> + Enum.any?(result, &(&1.id == id)) + end) + end - member_both_true = member_both_true |> Ash.load!(:custom_field_values) + test "apply_boolean_custom_field_filters applies multiple filters with AND logic", %{ + conn: _conn + } do + boolean_field1 = create_boolean_custom_field(%{name: "Field1"}) + boolean_field2 = create_boolean_custom_field(%{name: "Field2"}) - # 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() + # 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() - {: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() + {: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() - {: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() + {: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() - member_mixed = member_mixed |> Ash.load!(:custom_field_values) + member_both_true = member_both_true |> Ash.load!(:custom_field_values) - members = [member_both_true, member_mixed] - filters = %{ - to_string(boolean_field1.id) => true, - to_string(boolean_field2.id) => true - } + # 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() - result = MvWeb.MemberLive.Index.apply_boolean_custom_field_filters(members, filters) + {: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() - # Only member_both_true should match (both fields = true) - assert length(result) == 1 - assert List.first(result).id == member_both_true.id - end + {: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() - test "apply_boolean_custom_field_filters ignores filter with non-existent custom field ID", %{conn: _conn} do - boolean_field = create_boolean_custom_field() - fake_id = Ecto.UUID.generate() + member_mixed = member_mixed |> Ash.load!(:custom_field_values) - member = create_member_with_boolean_value(%{first_name: "Member"}, boolean_field, true) + members = [member_both_true, member_mixed] - members = [member] - filters = %{fake_id => true} + filters = %{ + to_string(boolean_field1.id) => true, + to_string(boolean_field2.id) => true + } - result = MvWeb.MemberLive.Index.apply_boolean_custom_field_filters(members, filters) + all_custom_fields = Mv.Membership.CustomField |> Ash.read!() - # Should return all members since fake_id doesn't match any custom field - assert length(result) == 1 - end + 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 + boolean_field = create_boolean_custom_field() + fake_id = Ecto.UUID.generate() + + member = create_member_with_boolean_value(%{first_name: "Member"}, boolean_field, true) + + 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 - conn = conn_with_oidc_user(conn) - boolean_field = create_boolean_custom_field() + test "boolean filter integration filters members by boolean custom field value via URL parameter", + %{conn: conn} do + 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) - member_with_false = create_member_with_boolean_value(%{first_name: "FalseMember"}, boolean_field, false) - {: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() + _member_with_true = + create_member_with_boolean_value(%{first_name: "TrueMember"}, boolean_field, true) - # Test true filter - {:ok, _view, html_true} = - live(conn, "/members?bf_#{boolean_field.id}=true") + _member_with_false = + create_member_with_boolean_value(%{first_name: "FalseMember"}, boolean_field, false) - assert html_true =~ "TrueMember" - refute html_true =~ "FalseMember" - refute html_true =~ "NoValue" + {: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() - # Test false filter - {:ok, _view, html_false} = - live(conn, "/members?bf_#{boolean_field.id}=false") + # Test true filter + {:ok, _view, html_true} = + live(conn, "/members?bf_#{boolean_field.id}=true") - assert html_false =~ "FalseMember" - refute html_false =~ "TrueMember" - refute html_false =~ "NoValue" - end + 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 - conn = conn_with_oidc_user(conn) - boolean_field = create_boolean_custom_field() - fee_type = create_fee_type(%{interval: :yearly}) - today = Date.utc_today() - last_year_start = Date.new!(today.year - 1, 1, 1) + conn = conn_with_oidc_user(conn) + boolean_field = create_boolean_custom_field() + fee_type = create_fee_type(%{interval: :yearly}) + 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() + # 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() - {: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() + {: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() - create_cycle(member_paid_true, fee_type, %{cycle_start: last_year_start, status: :paid}) + create_cycle(member_paid_true, fee_type, %{cycle_start: last_year_start, status: :paid}) - # 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() + # 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() - {: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() + {: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() - create_cycle(member_unpaid_true, fee_type, %{cycle_start: last_year_start, status: :unpaid}) + create_cycle(member_unpaid_true, fee_type, %{cycle_start: last_year_start, status: :unpaid}) - # Test both filters together - {:ok, view, html} = - live(conn, "/members?cycle_status_filter=paid&bf_#{boolean_field.id}=true") + # 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 + # 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 - conn = conn_with_oidc_user(conn) - boolean_field = create_boolean_custom_field() + 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) - member_with_false = create_member_with_boolean_value(%{first_name: "FalseMember"}, boolean_field, false) + _member_with_true = + create_member_with_boolean_value(%{first_name: "TrueMember"}, boolean_field, true) - # Test search + boolean filter - {:ok, view, html} = - live(conn, "/members?query=TrueMember&bf_#{boolean_field.id}=true") + _member_with_false = + create_member_with_boolean_value(%{first_name: "FalseMember"}, boolean_field, false) - # Only member_with_true should match both search and filter - assert html =~ "TrueMember" - refute html =~ "FalseMember" - end + # 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 end end From fbf3b64192df4c8076f045e7d2aca847922fd79a Mon Sep 17 00:00:00 2001 From: Simon Date: Tue, 20 Jan 2026 18:34:17 +0100 Subject: [PATCH 05/13] refactor: fix credo issues --- lib/mv_web/live/member_live/index.ex | 98 +++++++++++++++++++------- test/mv_web/member_live/index_test.exs | 4 +- 2 files changed, 74 insertions(+), 28 deletions(-) diff --git a/lib/mv_web/live/member_live/index.ex b/lib/mv_web/live/member_live/index.ex index 6eba629..8e3d5f1 100644 --- a/lib/mv_web/live/member_live/index.ex +++ b/lib/mv_web/live/member_live/index.ex @@ -1194,32 +1194,13 @@ defmodule MvWeb.MemberLive.Index do params |> Enum.filter(fn {key, _value} -> String.starts_with?(key, @boolean_filter_prefix) end) |> Enum.reduce(%{}, fn {key, value_str}, acc -> - # 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 - # Validate custom field ID exists and is boolean type - case Ecto.UUID.cast(custom_field_id_str) do - {:ok, _custom_field_id} -> - if Map.has_key?(boolean_custom_fields, custom_field_id_str) do - # Validate filter value - case determine_boolean_filter(value_str) do - nil -> acc - filter_value -> Map.put(acc, custom_field_id_str, filter_value) - end - else - acc - end - - :error -> - acc - end - else + process_boolean_filter_param( + key, + value_str, + prefix_length, + boolean_custom_fields, acc - end + ) end) # Security: Limit number of filters to prevent DoS attacks @@ -1240,6 +1221,73 @@ defmodule MvWeb.MemberLive.Index do 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" diff --git a/test/mv_web/member_live/index_test.exs b/test/mv_web/member_live/index_test.exs index e5330da..20cfd68 100644 --- a/test/mv_web/member_live/index_test.exs +++ b/test/mv_web/member_live/index_test.exs @@ -945,9 +945,7 @@ defmodule MvWeb.MemberLive.IndexTest do # Build URL with all 60 filters filter_params = - boolean_fields - |> Enum.map(fn cf -> "bf_#{cf.id}=true" end) - |> Enum.join("&") + Enum.map_join(boolean_fields, "&", fn cf -> "bf_#{cf.id}=true" end) {:ok, view, _html} = live(conn, "/members?#{filter_params}") From 1011b94acfedb6a0e5d9df6ae8b5cd11387addc4 Mon Sep 17 00:00:00 2001 From: Simon Date: Tue, 20 Jan 2026 19:12:13 +0100 Subject: [PATCH 06/13] feat: load boolean custom fields --- lib/mv_web/live/member_live/index.ex | 7 ++++ test/mv_web/member_live/index_test.exs | 48 ++++++++++++++++++++++++++ 2 files changed, 55 insertions(+) diff --git a/lib/mv_web/live/member_live/index.ex b/lib/mv_web/live/member_live/index.ex index 8e3d5f1..bba6d8a 100644 --- a/lib/mv_web/live/member_live/index.ex +++ b/lib/mv_web/live/member_live/index.ex @@ -84,6 +84,12 @@ defmodule MvWeb.MemberLive.Index do |> Ash.Query.sort(name: :asc) |> Ash.read!(actor: actor) + # Load boolean custom fields (filtered and sorted from all_custom_fields) + boolean_custom_fields = + all_custom_fields + |> Enum.filter(&(&1.value_type == :boolean)) + |> Enum.sort_by(& &1.name, :asc) + # Load settings once to avoid N+1 queries settings = case Membership.get_settings() do @@ -118,6 +124,7 @@ defmodule MvWeb.MemberLive.Index do |> 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( diff --git a/test/mv_web/member_live/index_test.exs b/test/mv_web/member_live/index_test.exs index 20cfd68..97dbd39 100644 --- a/test/mv_web/member_live/index_test.exs +++ b/test/mv_web/member_live/index_test.exs @@ -696,6 +696,54 @@ defmodule MvWeb.MemberLive.IndexTest do 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() From f996aee6b2c1d7a8ac437484905dcb579bcc68c0 Mon Sep 17 00:00:00 2001 From: Simon Date: Wed, 21 Jan 2026 00:47:01 +0100 Subject: [PATCH 07/13] feat: add new filter component to members view --- .../components/member_filter_component.ex | 454 ++++++++++++++++++ .../components/payment_filter_component.ex | 147 ------ lib/mv_web/live/member_live/index.ex | 38 ++ lib/mv_web/live/member_live/index.html.heex | 6 +- priv/gettext/de/LC_MESSAGES/default.po | 328 ++----------- priv/gettext/default.pot | 55 ++- priv/gettext/en/LC_MESSAGES/default.po | 69 ++- .../member_filter_component_test.exs | 267 ++++++++++ .../payment_filter_component_test.exs | 183 ------- 9 files changed, 891 insertions(+), 656 deletions(-) create mode 100644 lib/mv_web/live/components/member_filter_component.ex delete mode 100644 lib/mv_web/live/components/payment_filter_component.ex create mode 100644 test/mv_web/components/member_filter_component_test.exs delete mode 100644 test/mv_web/components/payment_filter_component_test.exs diff --git a/lib/mv_web/live/components/member_filter_component.ex b/lib/mv_web/live/components/member_filter_component.ex new file mode 100644 index 0000000..657cb02 --- /dev/null +++ b/lib/mv_web/live/components/member_filter_component.ex @@ -0,0 +1,454 @@ +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, radio inputs), not menu items. + - 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""" +
+ + + + +
+ """ + 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 + # Reset payment filter + if socket.assigns.cycle_status_filter != nil do + send(self(), {:payment_filter_changed, nil}) + end + + # Reset all boolean filters + Enum.each(socket.assigns.boolean_filters, fn {custom_field_id_str, _value} -> + send(self(), {:boolean_filter_changed, custom_field_id_str, nil}) + end) + + # Close dropdown after reset + {:noreply, assign(socket, :open, false)} + end + + # Parse tri-state filter value: "all" | "true" | "false" -> nil | true | false + defp parse_tri_state("true"), do: true + defp parse_tri_state("false"), do: false + defp parse_tri_state("all"), do: nil + defp parse_tri_state(_), do: nil + + # Get display label for button + defp button_label(cycle_status_filter, boolean_custom_fields, boolean_filters) do + # If payment filter is active, show payment filter label + if cycle_status_filter do + payment_filter_label(cycle_status_filter) + else + # Otherwise show boolean filter labels + boolean_filter_label(boolean_custom_fields, boolean_filters) + end + end + + # Get payment filter label + defp payment_filter_label(nil), do: gettext("All") + defp payment_filter_label(:paid), do: gettext("Paid") + defp payment_filter_label(:unpaid), do: gettext("Unpaid") + + # Get boolean filter label (comma-separated list of active filter names) + defp boolean_filter_label(_boolean_custom_fields, boolean_filters) + when map_size(boolean_filters) == 0 do + gettext("All") + end + + defp boolean_filter_label(boolean_custom_fields, boolean_filters) do + # Get names of active boolean filters + active_filter_names = + boolean_filters + |> Enum.map(fn {custom_field_id_str, _value} -> + Enum.find(boolean_custom_fields, fn cf -> to_string(cf.id) == custom_field_id_str end) + end) + |> Enum.filter(&(&1 != nil)) + |> Enum.map(& &1.name) + + # Join with comma and truncate if too long + label = Enum.join(active_filter_names, ", ") + truncate_label(label, 30) + end + + # Truncate label if longer than max_length + defp truncate_label(label, max_length) when byte_size(label) <= max_length, do: label + + defp truncate_label(label, max_length) do + String.slice(label, 0, max_length) <> "..." + end + + # Count active boolean filters + defp active_boolean_filters_count(boolean_filters) do + map_size(boolean_filters) + end + + # Get CSS classes for payment filter label based on current state + defp payment_filter_label_class(current_filter, expected_value) do + base_classes = "join-item btn btn-sm" + is_active = current_filter == expected_value + + cond do + # All button (nil expected) + expected_value == nil -> + if is_active do + "#{base_classes} btn-active" + else + "#{base_classes} btn" + end + + # Paid button + expected_value == :paid -> + if is_active do + "#{base_classes} btn-success btn-active" + else + "#{base_classes} btn" + end + + # Unpaid button + expected_value == :unpaid -> + if is_active do + "#{base_classes} btn-error btn-active" + else + "#{base_classes} btn" + end + + true -> + "#{base_classes} btn-outline" + end + end + + # Get CSS classes for boolean filter label based on current state + defp boolean_filter_label_class(boolean_filters, custom_field_id, expected_value) do + base_classes = "join-item btn btn-sm" + current_value = Map.get(boolean_filters, to_string(custom_field_id)) + is_active = current_value == expected_value + + cond do + # All button (nil expected) + expected_value == nil -> + if is_active do + "#{base_classes} btn-active" + else + "#{base_classes} btn" + end + + # True button + expected_value == true -> + if is_active do + "#{base_classes} btn-success btn-active" + else + "#{base_classes} btn" + end + + # False button + expected_value == false -> + if is_active do + "#{base_classes} btn-error btn-active" + else + "#{base_classes} btn" + end + + true -> + "#{base_classes} btn-outline" + end + end +end diff --git a/lib/mv_web/live/components/payment_filter_component.ex b/lib/mv_web/live/components/payment_filter_component.ex deleted file mode 100644 index 9caaa1f..0000000 --- a/lib/mv_web/live/components/payment_filter_component.ex +++ /dev/null @@ -1,147 +0,0 @@ -defmodule MvWeb.Components.PaymentFilterComponent do - @moduledoc """ - Provides the PaymentFilter Live-Component. - - A dropdown filter for filtering members by cycle payment status (paid/unpaid/all). - Uses DaisyUI dropdown styling and sends filter changes to parent LiveView. - Filter is based on cycle status (last or current cycle, depending on cycle view toggle). - - ## Props - - `:cycle_status_filter` - Current filter state: `nil` (all), `:paid`, or `:unpaid` - - `:id` - Component ID (required) - - `:member_count` - Number of filtered members to display in badge (optional, default: 0) - - ## Events - - Sends `{:payment_filter_changed, filter}` to parent when filter changes - """ - use MvWeb, :live_component - - @impl true - def mount(socket) do - {:ok, assign(socket, :open, false)} - end - - @impl true - def update(assigns, socket) do - socket = - socket - |> assign(:id, assigns.id) - |> assign(:cycle_status_filter, assigns[:cycle_status_filter]) - |> assign(:member_count, assigns[:member_count] || 0) - - {:ok, socket} - end - - @impl true - def render(assigns) do - ~H""" -
- - - -
- """ - 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 bba6d8a..9fc9cdf 100644 --- a/lib/mv_web/live/member_live/index.ex +++ b/lib/mv_web/live/member_live/index.ex @@ -396,6 +396,44 @@ defmodule MvWeb.MemberLive.Index do )} 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({:field_toggled, field_string, visible}, socket) do # Update user field selection diff --git a/lib/mv_web/live/member_live/index.html.heex b/lib/mv_web/live/member_live/index.html.heex index b2af205..394db2c 100644 --- a/lib/mv_web/live/member_live/index.html.heex +++ b/lib/mv_web/live/member_live/index.html.heex @@ -37,9 +37,11 @@ placeholder={gettext("Search...")} /> <.live_component - module={MvWeb.Components.PaymentFilterComponent} - id="payment-filter" + module={MvWeb.Components.MemberFilterComponent} + id="member-filter" cycle_status_filter={@cycle_status_filter} + boolean_custom_fields={@boolean_custom_fields} + boolean_filters={@boolean_custom_field_filters} member_count={length(@members)} />