feat: implement filter logic for boolean ustom fields
Some checks failed
continuous-integration/drone/push Build is failing

This commit is contained in:
Simon 2026-01-20 18:01:25 +01:00
parent d65da2f498
commit ff8b29cffe
Signed by: simon
GPG key ID: 40E7A58C4AA1EDB2
2 changed files with 558 additions and 321 deletions

View file

@ -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)
_ ->

View file

@ -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, %{
|> 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))
}
|> Map.merge(member_attrs)
)
|> Ash.create()
{:ok, _cfv} =
@ -1029,8 +1033,10 @@ defmodule MvWeb.MemberLive.IndexTest do
assert result == false
end
test "get_boolean_custom_field_value extracts true from map format with type and value keys", %{conn: _conn} do
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()
{:ok, member} =
Mv.Membership.Member
|> Ash.Changeset.for_create(:create_member, %{
@ -1040,13 +1046,13 @@ defmodule MvWeb.MemberLive.IndexTest do
})
|> Ash.create()
# Create CustomFieldValue with map format
# 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: %{"type" => "boolean", "value" => true}
value: %{"_union_type" => "boolean", "_union_value" => true}
})
|> Ash.create()
@ -1058,8 +1064,11 @@ defmodule MvWeb.MemberLive.IndexTest do
assert result == true
end
test "get_boolean_custom_field_value returns nil when no CustomFieldValue exists", %{conn: _conn} do
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, %{
@ -1077,8 +1086,11 @@ defmodule MvWeb.MemberLive.IndexTest do
assert result == nil
end
test "get_boolean_custom_field_value returns nil when CustomFieldValue has nil value", %{conn: _conn} do
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, %{
@ -1105,7 +1117,9 @@ defmodule MvWeb.MemberLive.IndexTest do
assert result == nil
end
test "get_boolean_custom_field_value returns nil for non-boolean CustomFieldValue", %{conn: _conn} do
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()
@ -1137,11 +1151,16 @@ defmodule MvWeb.MemberLive.IndexTest do
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
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)
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, %{
@ -1155,8 +1174,14 @@ defmodule MvWeb.MemberLive.IndexTest do
members = [member_with_true, member_with_false, member_without_value]
filters = %{to_string(boolean_field.id) => true}
all_custom_fields = Mv.Membership.CustomField |> Ash.read!()
result = MvWeb.MemberLive.Index.apply_boolean_custom_field_filters(members, filters)
result =
MvWeb.MemberLive.Index.apply_boolean_custom_field_filters(
members,
filters,
all_custom_fields
)
assert length(result) == 1
assert List.first(result).id == member_with_true.id
@ -1164,11 +1189,16 @@ defmodule MvWeb.MemberLive.IndexTest do
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
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)
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, %{
@ -1182,8 +1212,14 @@ defmodule MvWeb.MemberLive.IndexTest do
members = [member_with_true, member_with_false, member_without_value]
filters = %{to_string(boolean_field.id) => false}
all_custom_fields = Mv.Membership.CustomField |> Ash.read!()
result = MvWeb.MemberLive.Index.apply_boolean_custom_field_filters(members, filters)
result =
MvWeb.MemberLive.Index.apply_boolean_custom_field_filters(
members,
filters,
all_custom_fields
)
assert length(result) == 1
assert List.first(result).id == member_with_false.id
@ -1191,7 +1227,9 @@ defmodule MvWeb.MemberLive.IndexTest do
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
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)
@ -1199,17 +1237,25 @@ defmodule MvWeb.MemberLive.IndexTest do
members = [member1, member2]
filters = %{}
all_custom_fields = Mv.Membership.CustomField |> Ash.read!()
result = MvWeb.MemberLive.Index.apply_boolean_custom_field_filters(members, filters)
result =
MvWeb.MemberLive.Index.apply_boolean_custom_field_filters(
members,
filters,
all_custom_fields
)
assert length(result) == 2
assert Enum.all?([member1.id, member2.id], fn id ->
Enum.any?(result, &(&1.id == id))
end)
end
test "apply_boolean_custom_field_filters applies multiple filters with AND logic", %{conn: _conn} do
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"})
@ -1274,19 +1320,29 @@ defmodule MvWeb.MemberLive.IndexTest do
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)
all_custom_fields = Mv.Membership.CustomField |> Ash.read!()
result =
MvWeb.MemberLive.Index.apply_boolean_custom_field_filters(
members,
filters,
all_custom_fields
)
# Only member_both_true should match (both fields = true)
assert length(result) == 1
assert List.first(result).id == member_both_true.id
end
test "apply_boolean_custom_field_filters ignores filter with non-existent custom field ID", %{conn: _conn} do
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()
@ -1294,21 +1350,32 @@ defmodule MvWeb.MemberLive.IndexTest do
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)
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
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} =
_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",
@ -1386,7 +1453,7 @@ defmodule MvWeb.MemberLive.IndexTest do
create_cycle(member_unpaid_true, fee_type, %{cycle_start: last_year_start, status: :unpaid})
# Test both filters together
{:ok, view, html} =
{:ok, _view, html} =
live(conn, "/members?cycle_status_filter=paid&bf_#{boolean_field.id}=true")
# Only member_paid_true should match both filters
@ -1398,11 +1465,14 @@ defmodule MvWeb.MemberLive.IndexTest 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)
_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} =
{:ok, _view, html} =
live(conn, "/members?query=TrueMember&bf_#{boolean_field.id}=true")
# Only member_with_true should match both search and filter