feat: implement filter logic for boolean ustom fields
This commit is contained in:
parent
b701b84260
commit
da9ec06e8e
2 changed files with 558 additions and 321 deletions
|
|
@ -768,6 +768,14 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
socket.assigns.show_current_cycle
|
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)
|
# Sort in memory if needed (for custom fields)
|
||||||
members =
|
members =
|
||||||
if sort_after_load do
|
if sort_after_load do
|
||||||
|
|
@ -1279,7 +1287,166 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
values when is_list(values) ->
|
values when is_list(values) ->
|
||||||
Enum.find(values, fn cfv ->
|
Enum.find(values, fn cfv ->
|
||||||
cfv.custom_field_id == custom_field.id or
|
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)
|
end)
|
||||||
|
|
||||||
_ ->
|
_ ->
|
||||||
|
|
|
||||||
|
|
@ -985,14 +985,18 @@ defmodule MvWeb.MemberLive.IndexTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
# Helper to create a member with a boolean custom field value
|
# 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} =
|
{:ok, member} =
|
||||||
Mv.Membership.Member
|
Mv.Membership.Member
|
||||||
|> Ash.Changeset.for_create(:create_member, %{
|
|> Ash.Changeset.for_create(
|
||||||
first_name: "Test",
|
:create_member,
|
||||||
last_name: "Member",
|
%{
|
||||||
email: "test.member.#{System.unique_integer([:positive])}@example.com"
|
first_name: "Test",
|
||||||
} |> Map.merge(member_attrs))
|
last_name: "Member",
|
||||||
|
email: "test.member.#{System.unique_integer([:positive])}@example.com"
|
||||||
|
}
|
||||||
|
|> Map.merge(member_attrs)
|
||||||
|
)
|
||||||
|> Ash.create()
|
|> Ash.create()
|
||||||
|
|
||||||
{:ok, _cfv} =
|
{:ok, _cfv} =
|
||||||
|
|
@ -1011,403 +1015,469 @@ defmodule MvWeb.MemberLive.IndexTest do
|
||||||
|
|
||||||
# Tests for get_boolean_custom_field_value/2
|
# Tests for get_boolean_custom_field_value/2
|
||||||
test "get_boolean_custom_field_value extracts true from Ash.Union format", %{conn: _conn} do
|
test "get_boolean_custom_field_value extracts true from Ash.Union format", %{conn: _conn} do
|
||||||
boolean_field = create_boolean_custom_field()
|
boolean_field = create_boolean_custom_field()
|
||||||
member = create_member_with_boolean_value(%{}, boolean_field, true)
|
member = create_member_with_boolean_value(%{}, boolean_field, true)
|
||||||
|
|
||||||
# Test the function (will fail until implemented)
|
# Test the function (will fail until implemented)
|
||||||
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 == true
|
assert result == true
|
||||||
end
|
end
|
||||||
|
|
||||||
test "get_boolean_custom_field_value extracts false from Ash.Union format", %{conn: _conn} do
|
test "get_boolean_custom_field_value extracts false from Ash.Union format", %{conn: _conn} do
|
||||||
boolean_field = create_boolean_custom_field()
|
boolean_field = create_boolean_custom_field()
|
||||||
member = create_member_with_boolean_value(%{}, boolean_field, false)
|
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
|
assert result == false
|
||||||
end
|
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",
|
||||||
boolean_field = create_boolean_custom_field()
|
%{conn: _conn} do
|
||||||
{:ok, member} =
|
boolean_field = create_boolean_custom_field()
|
||||||
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, member} =
|
||||||
{:ok, _cfv} =
|
Mv.Membership.Member
|
||||||
Mv.Membership.CustomFieldValue
|
|> Ash.Changeset.for_create(:create_member, %{
|
||||||
|> Ash.Changeset.for_create(:create, %{
|
first_name: "Test",
|
||||||
member_id: member.id,
|
last_name: "Member",
|
||||||
custom_field_id: boolean_field.id,
|
email: "test.member.#{System.unique_integer([:positive])}@example.com"
|
||||||
value: %{"type" => "boolean", "value" => true}
|
})
|
||||||
})
|
|> Ash.create()
|
||||||
|> Ash.create()
|
|
||||||
|
|
||||||
# Reload member with custom field values
|
# Create CustomFieldValue with map format (Ash expects _union_type and _union_value)
|
||||||
member = member |> Ash.load!(:custom_field_values)
|
{: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
|
result = MvWeb.MemberLive.Index.get_boolean_custom_field_value(member, boolean_field)
|
||||||
end
|
|
||||||
|
|
||||||
test "get_boolean_custom_field_value returns nil when no CustomFieldValue exists", %{conn: _conn} do
|
assert result == true
|
||||||
boolean_field = create_boolean_custom_field()
|
end
|
||||||
{: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
|
test "get_boolean_custom_field_value returns nil when no CustomFieldValue exists", %{
|
||||||
member = member |> Ash.load!(:custom_field_values)
|
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
|
# Member has no custom field value for this field
|
||||||
end
|
member = member |> Ash.load!(:custom_field_values)
|
||||||
|
|
||||||
test "get_boolean_custom_field_value returns nil when CustomFieldValue has nil value", %{conn: _conn} do
|
result = MvWeb.MemberLive.Index.get_boolean_custom_field_value(member, boolean_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 CustomFieldValue with nil value (edge case)
|
assert result == nil
|
||||||
{:ok, _cfv} =
|
end
|
||||||
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)
|
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
|
# Create CustomFieldValue with nil value (edge case)
|
||||||
end
|
{: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
|
member = member |> Ash.load!(:custom_field_values)
|
||||||
string_field = create_string_custom_field()
|
|
||||||
boolean_field = create_boolean_custom_field()
|
|
||||||
|
|
||||||
{:ok, member} =
|
result = MvWeb.MemberLive.Index.get_boolean_custom_field_value(member, boolean_field)
|
||||||
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)
|
assert result == nil
|
||||||
{:ok, _cfv} =
|
end
|
||||||
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)
|
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
|
{:ok, member} =
|
||||||
result = MvWeb.MemberLive.Index.get_boolean_custom_field_value(member, boolean_field)
|
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
|
# Create string custom field value (not boolean)
|
||||||
end
|
{: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
|
# 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",
|
||||||
boolean_field = create_boolean_custom_field()
|
%{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_true =
|
||||||
member_with_false = create_member_with_boolean_value(%{first_name: "FalseMember"}, boolean_field, false)
|
create_member_with_boolean_value(%{first_name: "TrueMember"}, boolean_field, 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()
|
|
||||||
|
|
||||||
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]
|
{:ok, member_without_value} =
|
||||||
filters = %{to_string(boolean_field.id) => true}
|
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
|
members = [member_with_true, member_with_false, member_without_value]
|
||||||
assert List.first(result).id == member_with_true.id
|
filters = %{to_string(boolean_field.id) => true}
|
||||||
refute Enum.any?(result, &(&1.id == member_with_false.id))
|
all_custom_fields = Mv.Membership.CustomField |> Ash.read!()
|
||||||
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
|
result =
|
||||||
boolean_field = create_boolean_custom_field()
|
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)
|
assert length(result) == 1
|
||||||
member_with_false = create_member_with_boolean_value(%{first_name: "FalseMember"}, boolean_field, false)
|
assert List.first(result).id == member_with_true.id
|
||||||
{:ok, member_without_value} =
|
refute Enum.any?(result, &(&1.id == member_with_false.id))
|
||||||
Mv.Membership.Member
|
refute Enum.any?(result, &(&1.id == member_without_value.id))
|
||||||
|> Ash.Changeset.for_create(:create_member, %{
|
end
|
||||||
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)
|
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]
|
member_with_true =
|
||||||
filters = %{to_string(boolean_field.id) => false}
|
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
|
{:ok, member_without_value} =
|
||||||
assert List.first(result).id == member_with_false.id
|
Mv.Membership.Member
|
||||||
refute Enum.any?(result, &(&1.id == member_with_true.id))
|
|> Ash.Changeset.for_create(:create_member, %{
|
||||||
refute Enum.any?(result, &(&1.id == member_without_value.id))
|
first_name: "NoValue",
|
||||||
end
|
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
|
member_without_value = member_without_value |> Ash.load!(:custom_field_values)
|
||||||
boolean_field = create_boolean_custom_field()
|
|
||||||
|
|
||||||
member1 = create_member_with_boolean_value(%{first_name: "Member1"}, boolean_field, true)
|
members = [member_with_true, member_with_false, member_without_value]
|
||||||
member2 = create_member_with_boolean_value(%{first_name: "Member2"}, boolean_field, false)
|
filters = %{to_string(boolean_field.id) => false}
|
||||||
|
all_custom_fields = Mv.Membership.CustomField |> Ash.read!()
|
||||||
|
|
||||||
members = [member1, member2]
|
result =
|
||||||
filters = %{}
|
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
|
test "apply_boolean_custom_field_filters returns all members when filter map is empty", %{
|
||||||
assert Enum.all?([member1.id, member2.id], fn id ->
|
conn: _conn
|
||||||
Enum.any?(result, &(&1.id == id))
|
} do
|
||||||
end)
|
boolean_field = create_boolean_custom_field()
|
||||||
end
|
|
||||||
|
|
||||||
|
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
|
members = [member1, member2]
|
||||||
boolean_field1 = create_boolean_custom_field(%{name: "Field1"})
|
filters = %{}
|
||||||
boolean_field2 = create_boolean_custom_field(%{name: "Field2"})
|
all_custom_fields = Mv.Membership.CustomField |> Ash.read!()
|
||||||
|
|
||||||
# Member with both fields = true
|
result =
|
||||||
{:ok, member_both_true} =
|
MvWeb.MemberLive.Index.apply_boolean_custom_field_filters(
|
||||||
Mv.Membership.Member
|
members,
|
||||||
|> Ash.Changeset.for_create(:create_member, %{
|
filters,
|
||||||
first_name: "BothTrue",
|
all_custom_fields
|
||||||
last_name: "Member",
|
)
|
||||||
email: "bothtrue.member.#{System.unique_integer([:positive])}@example.com"
|
|
||||||
})
|
|
||||||
|> Ash.create()
|
|
||||||
|
|
||||||
{:ok, _cfv1} =
|
assert length(result) == 2
|
||||||
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} =
|
assert Enum.all?([member1.id, member2.id], fn id ->
|
||||||
Mv.Membership.CustomFieldValue
|
Enum.any?(result, &(&1.id == id))
|
||||||
|> Ash.Changeset.for_create(:create, %{
|
end)
|
||||||
member_id: member_both_true.id,
|
end
|
||||||
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)
|
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
|
# Member with both fields = true
|
||||||
{:ok, member_mixed} =
|
{:ok, member_both_true} =
|
||||||
Mv.Membership.Member
|
Mv.Membership.Member
|
||||||
|> Ash.Changeset.for_create(:create_member, %{
|
|> Ash.Changeset.for_create(:create_member, %{
|
||||||
first_name: "Mixed",
|
first_name: "BothTrue",
|
||||||
last_name: "Member",
|
last_name: "Member",
|
||||||
email: "mixed.member.#{System.unique_integer([:positive])}@example.com"
|
email: "bothtrue.member.#{System.unique_integer([:positive])}@example.com"
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create()
|
||||||
|
|
||||||
{:ok, _cfv3} =
|
{:ok, _cfv1} =
|
||||||
Mv.Membership.CustomFieldValue
|
Mv.Membership.CustomFieldValue
|
||||||
|> Ash.Changeset.for_create(:create, %{
|
|> Ash.Changeset.for_create(:create, %{
|
||||||
member_id: member_mixed.id,
|
member_id: member_both_true.id,
|
||||||
custom_field_id: boolean_field1.id,
|
custom_field_id: boolean_field1.id,
|
||||||
value: %{"_union_type" => "boolean", "_union_value" => true}
|
value: %{"_union_type" => "boolean", "_union_value" => true}
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create()
|
||||||
|
|
||||||
{:ok, _cfv4} =
|
{:ok, _cfv2} =
|
||||||
Mv.Membership.CustomFieldValue
|
Mv.Membership.CustomFieldValue
|
||||||
|> Ash.Changeset.for_create(:create, %{
|
|> Ash.Changeset.for_create(:create, %{
|
||||||
member_id: member_mixed.id,
|
member_id: member_both_true.id,
|
||||||
custom_field_id: boolean_field2.id,
|
custom_field_id: boolean_field2.id,
|
||||||
value: %{"_union_type" => "boolean", "_union_value" => false}
|
value: %{"_union_type" => "boolean", "_union_value" => true}
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> 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]
|
# Member with field1 = true, field2 = false
|
||||||
filters = %{
|
{:ok, member_mixed} =
|
||||||
to_string(boolean_field1.id) => true,
|
Mv.Membership.Member
|
||||||
to_string(boolean_field2.id) => true
|
|> 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)
|
{:ok, _cfv4} =
|
||||||
assert length(result) == 1
|
Mv.Membership.CustomFieldValue
|
||||||
assert List.first(result).id == member_both_true.id
|
|> Ash.Changeset.for_create(:create, %{
|
||||||
end
|
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
|
member_mixed = member_mixed |> Ash.load!(:custom_field_values)
|
||||||
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_both_true, member_mixed]
|
||||||
|
|
||||||
members = [member]
|
filters = %{
|
||||||
filters = %{fake_id => true}
|
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
|
result =
|
||||||
assert length(result) == 1
|
MvWeb.MemberLive.Index.apply_boolean_custom_field_filters(
|
||||||
end
|
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
|
# 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_with_oidc_user(conn)
|
%{conn: conn} do
|
||||||
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_true =
|
||||||
member_with_false = create_member_with_boolean_value(%{first_name: "FalseMember"}, boolean_field, false)
|
create_member_with_boolean_value(%{first_name: "TrueMember"}, boolean_field, 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()
|
|
||||||
|
|
||||||
# Test true filter
|
_member_with_false =
|
||||||
{:ok, _view, html_true} =
|
create_member_with_boolean_value(%{first_name: "FalseMember"}, boolean_field, false)
|
||||||
live(conn, "/members?bf_#{boolean_field.id}=true")
|
|
||||||
|
|
||||||
assert html_true =~ "TrueMember"
|
{:ok, _member_without_value} =
|
||||||
refute html_true =~ "FalseMember"
|
Mv.Membership.Member
|
||||||
refute html_true =~ "NoValue"
|
|> 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
|
# Test true filter
|
||||||
{:ok, _view, html_false} =
|
{:ok, _view, html_true} =
|
||||||
live(conn, "/members?bf_#{boolean_field.id}=false")
|
live(conn, "/members?bf_#{boolean_field.id}=true")
|
||||||
|
|
||||||
assert html_false =~ "FalseMember"
|
assert html_true =~ "TrueMember"
|
||||||
refute html_false =~ "TrueMember"
|
refute html_true =~ "FalseMember"
|
||||||
refute html_false =~ "NoValue"
|
refute html_true =~ "NoValue"
|
||||||
end
|
|
||||||
|
# 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
|
test "boolean filter integration works together with cycle_status_filter", %{conn: conn} do
|
||||||
conn = conn_with_oidc_user(conn)
|
conn = conn_with_oidc_user(conn)
|
||||||
boolean_field = create_boolean_custom_field()
|
boolean_field = create_boolean_custom_field()
|
||||||
fee_type = create_fee_type(%{interval: :yearly})
|
fee_type = create_fee_type(%{interval: :yearly})
|
||||||
today = Date.utc_today()
|
today = Date.utc_today()
|
||||||
last_year_start = Date.new!(today.year - 1, 1, 1)
|
last_year_start = Date.new!(today.year - 1, 1, 1)
|
||||||
|
|
||||||
# Member with true boolean value and paid status
|
# Member with true boolean value and paid status
|
||||||
{:ok, member_paid_true} =
|
{:ok, member_paid_true} =
|
||||||
Mv.Membership.Member
|
Mv.Membership.Member
|
||||||
|> Ash.Changeset.for_create(:create_member, %{
|
|> Ash.Changeset.for_create(:create_member, %{
|
||||||
first_name: "PaidTrue",
|
first_name: "PaidTrue",
|
||||||
last_name: "Member",
|
last_name: "Member",
|
||||||
email: "paidtrue.member.#{System.unique_integer([:positive])}@example.com",
|
email: "paidtrue.member.#{System.unique_integer([:positive])}@example.com",
|
||||||
membership_fee_type_id: fee_type.id
|
membership_fee_type_id: fee_type.id
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create()
|
||||||
|
|
||||||
{:ok, _cfv} =
|
{:ok, _cfv} =
|
||||||
Mv.Membership.CustomFieldValue
|
Mv.Membership.CustomFieldValue
|
||||||
|> Ash.Changeset.for_create(:create, %{
|
|> Ash.Changeset.for_create(:create, %{
|
||||||
member_id: member_paid_true.id,
|
member_id: member_paid_true.id,
|
||||||
custom_field_id: boolean_field.id,
|
custom_field_id: boolean_field.id,
|
||||||
value: %{"_union_type" => "boolean", "_union_value" => true}
|
value: %{"_union_type" => "boolean", "_union_value" => true}
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> 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
|
# Member with true boolean value but unpaid status
|
||||||
{:ok, member_unpaid_true} =
|
{:ok, member_unpaid_true} =
|
||||||
Mv.Membership.Member
|
Mv.Membership.Member
|
||||||
|> Ash.Changeset.for_create(:create_member, %{
|
|> Ash.Changeset.for_create(:create_member, %{
|
||||||
first_name: "UnpaidTrue",
|
first_name: "UnpaidTrue",
|
||||||
last_name: "Member",
|
last_name: "Member",
|
||||||
email: "unpaidtrue.member.#{System.unique_integer([:positive])}@example.com",
|
email: "unpaidtrue.member.#{System.unique_integer([:positive])}@example.com",
|
||||||
membership_fee_type_id: fee_type.id
|
membership_fee_type_id: fee_type.id
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> Ash.create()
|
||||||
|
|
||||||
{:ok, _cfv2} =
|
{:ok, _cfv2} =
|
||||||
Mv.Membership.CustomFieldValue
|
Mv.Membership.CustomFieldValue
|
||||||
|> Ash.Changeset.for_create(:create, %{
|
|> Ash.Changeset.for_create(:create, %{
|
||||||
member_id: member_unpaid_true.id,
|
member_id: member_unpaid_true.id,
|
||||||
custom_field_id: boolean_field.id,
|
custom_field_id: boolean_field.id,
|
||||||
value: %{"_union_type" => "boolean", "_union_value" => true}
|
value: %{"_union_type" => "boolean", "_union_value" => true}
|
||||||
})
|
})
|
||||||
|> Ash.create()
|
|> 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
|
# Test both filters together
|
||||||
{:ok, view, html} =
|
{:ok, _view, html} =
|
||||||
live(conn, "/members?cycle_status_filter=paid&bf_#{boolean_field.id}=true")
|
live(conn, "/members?cycle_status_filter=paid&bf_#{boolean_field.id}=true")
|
||||||
|
|
||||||
# Only member_paid_true should match both filters
|
# Only member_paid_true should match both filters
|
||||||
assert html =~ "PaidTrue"
|
assert html =~ "PaidTrue"
|
||||||
refute html =~ "UnpaidTrue"
|
refute html =~ "UnpaidTrue"
|
||||||
end
|
end
|
||||||
|
|
||||||
test "boolean filter integration works together with search query", %{conn: conn} do
|
test "boolean filter integration works together with search query", %{conn: conn} do
|
||||||
conn = conn_with_oidc_user(conn)
|
conn = conn_with_oidc_user(conn)
|
||||||
boolean_field = create_boolean_custom_field()
|
boolean_field = create_boolean_custom_field()
|
||||||
|
|
||||||
member_with_true = create_member_with_boolean_value(%{first_name: "TrueMember"}, boolean_field, true)
|
_member_with_true =
|
||||||
member_with_false = create_member_with_boolean_value(%{first_name: "FalseMember"}, boolean_field, false)
|
create_member_with_boolean_value(%{first_name: "TrueMember"}, boolean_field, true)
|
||||||
|
|
||||||
# Test search + boolean filter
|
_member_with_false =
|
||||||
{:ok, view, html} =
|
create_member_with_boolean_value(%{first_name: "FalseMember"}, boolean_field, false)
|
||||||
live(conn, "/members?query=TrueMember&bf_#{boolean_field.id}=true")
|
|
||||||
|
|
||||||
# Only member_with_true should match both search and filter
|
# Test search + boolean filter
|
||||||
assert html =~ "TrueMember"
|
{:ok, _view, html} =
|
||||||
refute html =~ "FalseMember"
|
live(conn, "/members?query=TrueMember&bf_#{boolean_field.id}=true")
|
||||||
end
|
|
||||||
|
# Only member_with_true should match both search and filter
|
||||||
|
assert html =~ "TrueMember"
|
||||||
|
refute html =~ "FalseMember"
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue