feat: improve groups fillter
Some checks failed
continuous-integration/drone/push Build is failing

This commit is contained in:
Simon 2026-02-13 17:45:51 +01:00
parent 3322efcdf6
commit 5fd7c0e7f6
Signed by: simon
GPG key ID: 40E7A58C4AA1EDB2
13 changed files with 583 additions and 258 deletions

View file

@ -16,6 +16,8 @@ defmodule MvWeb.Components.MemberFilterComponent do
## Props
- `:cycle_status_filter` - Current payment filter state: `nil` (all), `:paid`, or `:unpaid`
- `:groups` - List of groups (for per-group filter rows)
- `:group_filters` - Map of active group filters: `%{group_id => :in | :not_in}` (nil = All for that group)
- `:boolean_custom_fields` - List of boolean custom fields to display
- `:boolean_filters` - Map of active boolean filters: `%{custom_field_id => true | false}`
- `:id` - Component ID (required)
@ -23,6 +25,7 @@ defmodule MvWeb.Components.MemberFilterComponent do
## Events
- Sends `{:payment_filter_changed, filter}` to parent when payment filter changes
- Sends `{:group_filter_changed, group_id_str, value}` to parent when a group filter changes (value: nil | :in | :not_in)
- Sends `{:boolean_filter_changed, custom_field_id, filter_value}` to parent when boolean filter changes
"""
use MvWeb, :live_component
@ -38,6 +41,8 @@ defmodule MvWeb.Components.MemberFilterComponent do
socket
|> assign(:id, assigns.id)
|> assign(:cycle_status_filter, assigns[:cycle_status_filter])
|> assign(:groups, assigns[:groups] || [])
|> assign(:group_filters, assigns[:group_filters] || %{})
|> assign(:boolean_custom_fields, assigns[:boolean_custom_fields] || [])
|> assign(:boolean_filters, assigns[:boolean_filters] || %{})
|> assign(:member_count, assigns[:member_count] || 0)
@ -60,7 +65,9 @@ defmodule MvWeb.Components.MemberFilterComponent do
tabindex="0"
class={[
"btn gap-2",
(@cycle_status_filter || active_boolean_filters_count(@boolean_filters) > 0) && "btn-active"
(@cycle_status_filter || map_size(@group_filters) > 0 ||
active_boolean_filters_count(@boolean_filters) > 0) &&
"btn-active"
]}
phx-click="toggle_dropdown"
phx-target={@myself}
@ -70,7 +77,13 @@ defmodule MvWeb.Components.MemberFilterComponent do
>
<.icon name="hero-funnel" class="h-5 w-5" />
<span class="hidden sm:inline">
{button_label(@cycle_status_filter, @boolean_custom_fields, @boolean_filters)}
{button_label(
@cycle_status_filter,
@groups,
@group_filters,
@boolean_custom_fields,
@boolean_filters
)}
</span>
<span
:if={active_boolean_filters_count(@boolean_filters) > 0}
@ -79,7 +92,10 @@ defmodule MvWeb.Components.MemberFilterComponent do
{active_boolean_filters_count(@boolean_filters)}
</span>
<span
:if={@cycle_status_filter && active_boolean_filters_count(@boolean_filters) == 0}
:if={
(@cycle_status_filter || map_size(@group_filters) > 0) &&
active_boolean_filters_count(@boolean_filters) == 0
}
class="badge badge-primary badge-sm"
>
{@member_count}
@ -103,7 +119,7 @@ defmodule MvWeb.Components.MemberFilterComponent do
role="dialog"
aria-label={gettext("Member filter")}
>
<form phx-change="update_filters" phx-target={@myself}>
<form phx-change="update_filters" phx-target={@myself} data-testid="member-filter-form">
<!-- Payment Filter Group -->
<div class="mb-4">
<div class="text-xs font-semibold opacity-70 mb-2 uppercase tracking-wider">
@ -162,6 +178,73 @@ defmodule MvWeb.Components.MemberFilterComponent do
</fieldset>
</div>
<!-- Groups: one row per group with All / Yes / No (like Custom Fields) -->
<div :if={length(@groups) > 0} class="mb-4">
<div class="text-xs font-semibold opacity-70 mb-2 uppercase tracking-wider">
{gettext("Groups")}
</div>
<div class="max-h-60 overflow-y-auto pr-2">
<fieldset
:for={group <- @groups}
class="grid grid-cols-[1fr_auto] items-center gap-3 py-2 border-b border-base-200 last:border-0 border-0 p-0 m-0 min-w-0"
>
<legend class="text-sm font-medium col-start-1 float-left w-auto">
{group.name}
</legend>
<div class="join col-start-2">
<label
class={"#{group_filter_label_class(@group_filters, group.id, nil)} has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-primary"}
for={"group-filter-#{group.id}-all"}
>
<input
type="radio"
id={"group-filter-#{group.id}-all"}
name={"group_#{group.id}"}
value="all"
class="absolute opacity-0 w-0 h-0 pointer-events-none"
checked={Map.get(@group_filters, to_string(group.id)) == nil}
/>
<span class="text-xs">{gettext("All")}</span>
</label>
<label
class={"#{group_filter_label_class(@group_filters, group.id, :in)} has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-primary"}
for={"group-filter-#{group.id}-in"}
aria-label={gettext("Yes")}
title={gettext("Yes")}
>
<input
type="radio"
id={"group-filter-#{group.id}-in"}
name={"group_#{group.id}"}
value="in"
class="absolute opacity-0 w-0 h-0 pointer-events-none"
checked={Map.get(@group_filters, to_string(group.id)) == :in}
/>
<.icon name="hero-check-circle" class="h-5 w-5" />
<span class="text-xs">{gettext("Yes")}</span>
</label>
<label
class={"#{group_filter_label_class(@group_filters, group.id, :not_in)} has-[:focus-visible]:ring-2 has-[:focus-visible]:ring-primary"}
for={"group-filter-#{group.id}-not-in"}
aria-label={gettext("No")}
title={gettext("No")}
>
<input
type="radio"
id={"group-filter-#{group.id}-not-in"}
name={"group_#{group.id}"}
value="not_in"
class="absolute opacity-0 w-0 h-0 pointer-events-none"
checked={Map.get(@group_filters, to_string(group.id)) == :not_in}
/>
<.icon name="hero-x-circle" class="h-5 w-5" />
<span class="text-xs">{gettext("No")}</span>
</label>
</div>
</fieldset>
</div>
</div>
<!-- Custom Fields Group -->
<div :if={length(@boolean_custom_fields) > 0} class="mb-2">
<div class="text-xs font-semibold opacity-70 mb-2 uppercase tracking-wider">
@ -274,6 +357,16 @@ defmodule MvWeb.Components.MemberFilterComponent do
_ -> nil
end
# Parse per-group filters (params keys "group_<uuid>" => "all"|"in"|"not_in")
group_filters_parsed =
params
|> Enum.filter(fn {key, _} -> String.starts_with?(key, "group_") end)
|> Enum.reduce(%{}, fn {key, value_str}, acc ->
group_id_str = String.slice(key, 6, String.length(key) - 6)
filter_value = parse_group_filter_value(value_str)
Map.put(acc, group_id_str, filter_value)
end)
# Parse boolean custom field filters (including nil values for "all")
custom_boolean_filters_parsed =
params
@ -288,6 +381,21 @@ defmodule MvWeb.Components.MemberFilterComponent do
send(self(), {:payment_filter_changed, payment_filter})
end
# Update group filters - send event for each changed group
current_group_filters = socket.assigns.group_filters
all_group_ids = MapSet.new(Enum.map(socket.assigns.groups, &to_string(&1.id)))
Enum.each(group_filters_parsed, fn {group_id_str, new_value} ->
in_set = MapSet.member?(all_group_ids, group_id_str)
current_value = Map.get(current_group_filters, group_id_str)
normalized_new = if new_value == nil, do: nil, else: new_value
should_send = in_set and current_value != normalized_new
if should_send do
send(self(), {:group_filter_changed, group_id_str, normalized_new})
end
end)
# Update boolean filters - send events for each changed filter
current_filters = socket.assigns.boolean_filters
@ -310,7 +418,7 @@ defmodule MvWeb.Components.MemberFilterComponent do
def handle_event("reset_filters", _params, socket) do
# Send single message to reset all filters at once (performance optimization)
# This avoids N×2 load_members() calls when resetting multiple filters
send(self(), {:reset_all_filters, nil, %{}})
send(self(), {:reset_all_filters, nil, %{}, %{}})
# Close dropdown after reset
{:noreply, assign(socket, :open, false)}
@ -322,17 +430,49 @@ defmodule MvWeb.Components.MemberFilterComponent do
defp parse_tri_state("all"), do: nil
defp parse_tri_state(_), do: nil
defp parse_group_filter_value("in"), do: :in
defp parse_group_filter_value("not_in"), do: :not_in
defp parse_group_filter_value(_), do: nil
# Get display label for button
defp button_label(cycle_status_filter, boolean_custom_fields, boolean_filters) do
# If payment filter is active, show payment filter label
if cycle_status_filter do
payment_filter_label(cycle_status_filter)
else
# Otherwise show boolean filter labels
boolean_filter_label(boolean_custom_fields, boolean_filters)
defp button_label(
cycle_status_filter,
groups,
group_filters,
boolean_custom_fields,
boolean_filters
) do
cond do
cycle_status_filter ->
payment_filter_label(cycle_status_filter)
map_size(group_filters) > 0 ->
group_filters_label(groups, group_filters)
map_size(boolean_filters) > 0 ->
boolean_filter_label(boolean_custom_fields, boolean_filters)
true ->
gettext("All")
end
end
defp group_filters_label(_groups, group_filters) when map_size(group_filters) == 0,
do: gettext("All")
defp group_filters_label(groups, group_filters) do
names =
group_filters
|> Enum.map(fn {group_id_str, _} ->
Enum.find(groups, fn g -> to_string(g.id) == group_id_str end)
end)
|> Enum.filter(&(&1 != nil))
|> Enum.map(& &1.name)
label = Enum.join(names, ", ")
truncate_label(label, 30)
end
# Get payment filter label
defp payment_filter_label(nil), do: gettext("All")
defp payment_filter_label(:paid), do: gettext("Paid")
@ -406,6 +546,39 @@ defmodule MvWeb.Components.MemberFilterComponent do
end
end
# Get CSS classes for per-group filter label based on current state
defp group_filter_label_class(group_filters, group_id, expected_value) do
base_classes = "join-item btn btn-sm"
current_value = Map.get(group_filters, to_string(group_id))
is_active = current_value == expected_value
cond do
expected_value == nil ->
if is_active do
"#{base_classes} btn-active"
else
"#{base_classes} btn"
end
expected_value == :in ->
if is_active do
"#{base_classes} btn-success btn-active"
else
"#{base_classes} btn"
end
expected_value == :not_in ->
if is_active do
"#{base_classes} btn-error btn-active"
else
"#{base_classes} btn"
end
true ->
"#{base_classes} btn-outline"
end
end
# Get CSS classes for boolean filter label based on current state
defp boolean_filter_label_class(boolean_filters, custom_field_id, expected_value) do
base_classes = "join-item btn btn-sm"

View file

@ -41,6 +41,7 @@ defmodule MvWeb.MemberLive.Index do
@custom_field_prefix Mv.Constants.custom_field_prefix()
@boolean_filter_prefix Mv.Constants.boolean_filter_prefix()
@group_filter_prefix "group_"
# Maximum number of boolean custom field filters allowed per request (DoS protection)
@max_boolean_filters Mv.Constants.max_boolean_filters()
@ -121,7 +122,7 @@ defmodule MvWeb.MemberLive.Index do
|> assign_new(:sort_field, fn -> :first_name end)
|> assign_new(:sort_order, fn -> :asc end)
|> assign(:cycle_status_filter, nil)
|> assign(:group_filter, nil)
|> assign(:group_filters, %{})
|> assign(:groups, groups)
|> assign(:boolean_custom_field_filters, %{})
|> assign(:selected_members, MapSet.new())
@ -250,7 +251,7 @@ defmodule MvWeb.MemberLive.Index do
socket.assigns.sort_field,
socket.assigns.sort_order,
socket.assigns.cycle_status_filter,
socket.assigns[:group_filter],
socket.assigns[:group_filters],
new_show_current,
socket.assigns.boolean_custom_field_filters
)
@ -264,35 +265,6 @@ defmodule MvWeb.MemberLive.Index do
{:noreply, push_patch(socket, to: new_path, replace: true)}
end
@impl true
def handle_event("group_filter_changed", %{"group_filter" => group_id_param}, socket) do
group_filter = normalize_group_filter(group_id_param, socket.assigns.groups)
socket =
socket
|> assign(:group_filter, group_filter)
|> load_members()
|> update_selection_assigns()
query_params =
build_query_params(
socket.assigns.query,
socket.assigns.sort_field,
socket.assigns.sort_order,
socket.assigns.cycle_status_filter,
group_filter,
socket.assigns.show_current_cycle,
socket.assigns.boolean_custom_field_filters
)
|> maybe_add_field_selection(
socket.assigns[:user_field_selection],
socket.assigns[:fields_in_url?] || false
)
new_path = ~p"/members?#{query_params}"
{:noreply, push_patch(socket, to: new_path, replace: true)}
end
@impl true
def handle_event("copy_emails", _params, socket) do
selected_ids = socket.assigns.selected_members
@ -390,7 +362,7 @@ defmodule MvWeb.MemberLive.Index do
export_sort_field(socket.assigns.sort_field),
export_sort_order(socket.assigns.sort_order),
socket.assigns.cycle_status_filter,
socket.assigns[:group_filter],
socket.assigns[:group_filters],
socket.assigns.show_current_cycle,
socket.assigns.boolean_custom_field_filters
)
@ -416,7 +388,7 @@ defmodule MvWeb.MemberLive.Index do
socket.assigns.sort_field,
socket.assigns.sort_order,
socket.assigns.cycle_status_filter,
socket.assigns[:group_filter],
socket.assigns[:group_filters],
socket.assigns.show_current_cycle,
socket.assigns.boolean_custom_field_filters
)
@ -444,7 +416,7 @@ defmodule MvWeb.MemberLive.Index do
socket.assigns.sort_field,
socket.assigns.sort_order,
filter,
socket.assigns[:group_filter],
socket.assigns[:group_filters],
socket.assigns.show_current_cycle,
socket.assigns.boolean_custom_field_filters
)
@ -478,7 +450,7 @@ defmodule MvWeb.MemberLive.Index do
socket.assigns.sort_field,
socket.assigns.sort_order,
socket.assigns.cycle_status_filter,
socket.assigns[:group_filter],
socket.assigns[:group_filters],
socket.assigns.show_current_cycle,
updated_filters
)
@ -491,12 +463,55 @@ defmodule MvWeb.MemberLive.Index do
{:noreply, push_patch(socket, to: new_path, replace: true)}
end
@impl true
def handle_info({:group_filter_changed, group_id_str, filter_value}, socket) do
normalized_id = normalize_uuid_string(group_id_str) || group_id_str
group_filters =
if filter_value == nil do
Map.delete(socket.assigns.group_filters, normalized_id)
else
Map.put(socket.assigns.group_filters, normalized_id, filter_value)
end
socket =
socket
|> assign(:group_filters, group_filters)
|> load_members()
|> update_selection_assigns()
query_params =
build_query_params(
socket.assigns.query,
socket.assigns.sort_field,
socket.assigns.sort_order,
socket.assigns.cycle_status_filter,
group_filters,
socket.assigns.show_current_cycle,
socket.assigns.boolean_custom_field_filters
)
|> maybe_add_field_selection(
socket.assigns[:user_field_selection],
socket.assigns[:fields_in_url?] || false
)
new_path = ~p"/members?#{query_params}"
{:noreply, push_patch(socket, to: new_path, replace: true)}
end
@impl true
def handle_info({:reset_all_filters, cycle_status_filter, boolean_filters}, socket) do
handle_info({:reset_all_filters, cycle_status_filter, boolean_filters, %{}}, socket)
end
def handle_info(
{:reset_all_filters, cycle_status_filter, boolean_filters, group_filters},
socket
) do
socket =
socket
|> assign(:cycle_status_filter, cycle_status_filter)
|> assign(:group_filter, nil)
|> assign(:group_filters, group_filters)
|> assign(:boolean_custom_field_filters, boolean_filters)
|> load_members()
|> update_selection_assigns()
@ -507,7 +522,7 @@ defmodule MvWeb.MemberLive.Index do
socket.assigns.sort_field,
socket.assigns.sort_order,
cycle_status_filter,
socket.assigns[:group_filter],
socket.assigns[:group_filters],
socket.assigns.show_current_cycle,
boolean_filters
)
@ -644,7 +659,7 @@ defmodule MvWeb.MemberLive.Index do
|> maybe_update_search(params)
|> maybe_update_sort(params)
|> maybe_update_cycle_status_filter(params)
|> maybe_update_group_filter(params)
|> maybe_update_group_filters(params)
|> maybe_update_boolean_filters(params)
|> maybe_update_show_current_cycle(params)
|> assign(:fields_in_url?, fields_in_url?)
@ -678,7 +693,7 @@ defmodule MvWeb.MemberLive.Index do
socket.assigns.sort_field,
socket.assigns.sort_order,
socket.assigns.cycle_status_filter,
socket.assigns[:group_filter],
socket.assigns[:group_filters],
socket.assigns.show_current_cycle,
socket.assigns.boolean_custom_field_filters,
socket.assigns.user_field_selection,
@ -772,7 +787,7 @@ defmodule MvWeb.MemberLive.Index do
socket.assigns.sort_field,
socket.assigns.sort_order,
socket.assigns.cycle_status_filter,
socket.assigns[:group_filter],
socket.assigns[:group_filters],
socket.assigns.show_current_cycle,
socket.assigns.boolean_custom_field_filters
)
@ -791,7 +806,7 @@ defmodule MvWeb.MemberLive.Index do
sort_field,
sort_order,
cycle_status_filter,
group_filter,
group_filters,
show_current_cycle,
boolean_filters
) do
@ -823,11 +838,10 @@ defmodule MvWeb.MemberLive.Index do
end
base_params =
if group_filter && group_filter != "" do
Map.put(base_params, "group_filter", to_string(group_filter))
else
base_params
end
Enum.reduce(group_filters, base_params, fn {group_id_str, value}, acc ->
param_value = if value == :in, do: "in", else: "not_in"
Map.put(acc, "#{@group_filter_prefix}#{group_id_str}", param_value)
end)
base_params =
if show_current_cycle do
@ -884,7 +898,7 @@ defmodule MvWeb.MemberLive.Index do
query = apply_search_filter(query, search_query)
query = apply_group_filter(query, socket.assigns[:group_filter])
query = apply_group_filters(query, socket.assigns[:group_filters], socket.assigns[:groups])
# Use ALL custom fields for sorting (not just show_in_overview subset)
custom_fields_for_sort = socket.assigns.all_custom_fields
@ -963,13 +977,50 @@ defmodule MvWeb.MemberLive.Index do
end
end
defp apply_group_filter(query, nil), do: query
defp apply_group_filter(query, ""), do: query
defp apply_group_filters(query, group_filters, _groups) when group_filters == %{}, do: query
defp apply_group_filter(query, group_id) when is_binary(group_id) do
Ash.Query.filter(query, expr(exists(groups, id == ^group_id)))
defp apply_group_filters(query, group_filters, groups) do
valid_ids =
groups
|> Enum.map(&normalize_uuid_string(to_string(&1.id)))
|> Enum.reject(&is_nil/1)
|> MapSet.new()
Enum.reduce(group_filters, query, fn {group_id_str, value}, q ->
member? = MapSet.member?(valid_ids, group_id_str)
if member? do
apply_one_group_filter(q, group_id_str, value)
else
q
end
end)
end
defp apply_one_group_filter(query, _group_id_str, nil), do: query
defp apply_one_group_filter(query, group_id_str, :in) do
case Ecto.UUID.cast(group_id_str) do
{:ok, group_uuid} ->
Ash.Query.filter(query, expr(exists(member_groups, group_id == ^group_uuid)))
_ ->
query
end
end
defp apply_one_group_filter(query, group_id_str, :not_in) do
case Ecto.UUID.cast(group_id_str) do
{:ok, group_uuid} ->
Ash.Query.filter(query, expr(not exists(member_groups, group_id == ^group_uuid)))
_ ->
query
end
end
defp apply_one_group_filter(query, _, _), do: query
defp apply_cycle_status_filter(members, nil, _show_current), do: members
defp apply_cycle_status_filter(members, status, show_current)
@ -1097,17 +1148,15 @@ defmodule MvWeb.MemberLive.Index do
end
defp sort_members_in_memory(members, field, order, custom_fields) do
cond do
field in [:groups, "groups"] ->
sort_members_by_groups(members, order)
if field in [:groups, "groups"] do
sort_members_by_groups(members, order)
else
custom_field_id_str = extract_custom_field_id(field)
true ->
custom_field_id_str = extract_custom_field_id(field)
case custom_field_id_str do
nil -> members
id_str -> sort_members_by_custom_field(members, id_str, order, custom_fields)
end
case custom_field_id_str do
nil -> members
id_str -> sort_members_by_custom_field(members, id_str, order, custom_fields)
end
end
end
@ -1223,8 +1272,12 @@ defmodule MvWeb.MemberLive.Index do
defp determine_field_after_computed_check(default, sf) when is_binary(sf) do
cond do
sf == "groups" -> :groups
custom_field_sort?(sf) -> if valid_sort_field?(sf), do: sf, else: default
sf == "groups" ->
:groups
custom_field_sort?(sf) ->
if valid_sort_field?(sf), do: sf, else: default
true ->
atom = safe_member_field_atom_only(sf)
if atom != nil and valid_sort_field?(atom), do: atom, else: default
@ -1257,31 +1310,61 @@ defmodule MvWeb.MemberLive.Index do
defp maybe_update_cycle_status_filter(socket, _params),
do: assign(socket, :cycle_status_filter, nil)
defp maybe_update_group_filter(socket, %{"group_filter" => group_id_param}) do
group_filter = normalize_group_filter(group_id_param, socket.assigns.groups)
assign(socket, :group_filter, group_filter)
defp maybe_update_group_filters(socket, params) when is_map(params) do
prefix = @group_filter_prefix
prefix_len = String.length(prefix)
group_param_entries =
params
|> Enum.filter(fn {key, _} ->
key_str = to_string(key)
String.starts_with?(key_str, prefix)
end)
filters =
Enum.reduce(group_param_entries, %{}, fn {key, value_str}, acc ->
add_group_filter_entry(acc, key, value_str, prefix_len)
end)
assign(socket, :group_filters, filters)
end
defp maybe_update_group_filter(socket, _params), do: socket
defp maybe_update_group_filters(socket, _), do: socket
defp normalize_group_filter("", _groups), do: nil
defp normalize_group_filter(nil, _groups), do: nil
defp add_group_filter_entry(acc, key, value_str, prefix_len) do
key_str = to_string(key)
raw_id = String.slice(key_str, prefix_len, String.length(key_str) - prefix_len)
group_id_str = normalize_uuid_string(raw_id)
valid_id? = group_id_str && String.length(group_id_str) <= @max_uuid_length
defp normalize_group_filter(group_id_param, groups) when is_binary(group_id_param) do
case Ecto.UUID.cast(group_id_param) do
{:ok, _uuid} ->
if Enum.any?(groups, fn g -> to_string(g.id) == group_id_param end) do
group_id_param
else
nil
end
:error ->
nil
if valid_id? do
case parse_group_filter_value(value_str) do
nil -> acc
value -> Map.put(acc, group_id_str, value)
end
else
acc
end
end
defp normalize_group_filter(_, _), do: nil
# Normalize UUID string so URL params match valid_ids (lowercase, canonical format)
defp normalize_uuid_string(raw) when is_binary(raw) do
case Ecto.UUID.cast(String.trim(raw)) do
{:ok, uuid} -> to_string(uuid)
_ -> raw
end
end
defp normalize_uuid_string(_), do: nil
defp parse_group_filter_value("in"), do: :in
defp parse_group_filter_value("not_in"), do: :not_in
defp parse_group_filter_value(val) when is_binary(val) do
parse_group_filter_value(String.trim(val))
end
defp parse_group_filter_value(_), do: nil
defp determine_cycle_status_filter("paid"), do: :paid
defp determine_cycle_status_filter("unpaid"), do: :unpaid

View file

@ -52,26 +52,12 @@
query={@query}
placeholder={gettext("Search...")}
/>
<form id="group-filter-form" phx-change="group_filter_changed" class="contents">
<select
name="group_filter"
class="select select-bordered select-sm max-w-xs"
aria-label={gettext("Filter by group")}
>
<option value="" selected={@group_filter == nil}>
{gettext("All groups")}
</option>
<%= for group <- @groups do %>
<option value={group.id} selected={@group_filter == to_string(group.id)}>
{group.name}
</option>
<% end %>
</select>
</form>
<.live_component
module={MvWeb.Components.MemberFilterComponent}
id="member-filter"
cycle_status_filter={@cycle_status_filter}
groups={@groups}
group_filters={@group_filters}
boolean_custom_fields={@boolean_custom_fields}
boolean_filters={@boolean_custom_field_filters}
member_count={length(@members)}