From ff8b29cffe697fec9f4bfb9cdc8ba6ca49746de7 Mon Sep 17 00:00:00 2001 From: Simon Date: Tue, 20 Jan 2026 18:01:25 +0100 Subject: [PATCH] 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