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

@ -85,6 +85,7 @@
- Many-to-many relationship with groups - Many-to-many relationship with groups
- Groups management UI (`/groups`) - Groups management UI (`/groups`)
- Filter and sort by groups in member list - Filter and sort by groups in member list
- Per-group filter in member list: one row per group with All / Yes / No (All/Alle); URL params `group_<uuid>=in|not_in`
- Groups displayed in member overview and detail views - Groups displayed in member overview and detail views
- ✅ **CSV Import** - Import members from CSV files (PR #359, #394, #395, closes #335, #336, #338, 2026-01-27) - ✅ **CSV Import** - Import members from CSV files (PR #359, #394, #395, closes #335, #336, #338, 2026-01-27)
- Member field import - Member field import

View file

@ -16,6 +16,8 @@ defmodule MvWeb.Components.MemberFilterComponent do
## Props ## Props
- `:cycle_status_filter` - Current payment filter state: `nil` (all), `:paid`, or `:unpaid` - `: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_custom_fields` - List of boolean custom fields to display
- `:boolean_filters` - Map of active boolean filters: `%{custom_field_id => true | false}` - `:boolean_filters` - Map of active boolean filters: `%{custom_field_id => true | false}`
- `:id` - Component ID (required) - `:id` - Component ID (required)
@ -23,6 +25,7 @@ defmodule MvWeb.Components.MemberFilterComponent do
## Events ## Events
- Sends `{:payment_filter_changed, filter}` to parent when payment filter changes - 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 - Sends `{:boolean_filter_changed, custom_field_id, filter_value}` to parent when boolean filter changes
""" """
use MvWeb, :live_component use MvWeb, :live_component
@ -38,6 +41,8 @@ defmodule MvWeb.Components.MemberFilterComponent do
socket socket
|> assign(:id, assigns.id) |> assign(:id, assigns.id)
|> assign(:cycle_status_filter, assigns[:cycle_status_filter]) |> 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_custom_fields, assigns[:boolean_custom_fields] || [])
|> assign(:boolean_filters, assigns[:boolean_filters] || %{}) |> assign(:boolean_filters, assigns[:boolean_filters] || %{})
|> assign(:member_count, assigns[:member_count] || 0) |> assign(:member_count, assigns[:member_count] || 0)
@ -60,7 +65,9 @@ defmodule MvWeb.Components.MemberFilterComponent do
tabindex="0" tabindex="0"
class={[ class={[
"btn gap-2", "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-click="toggle_dropdown"
phx-target={@myself} phx-target={@myself}
@ -70,7 +77,13 @@ defmodule MvWeb.Components.MemberFilterComponent do
> >
<.icon name="hero-funnel" class="h-5 w-5" /> <.icon name="hero-funnel" class="h-5 w-5" />
<span class="hidden sm:inline"> <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>
<span <span
:if={active_boolean_filters_count(@boolean_filters) > 0} :if={active_boolean_filters_count(@boolean_filters) > 0}
@ -79,7 +92,10 @@ defmodule MvWeb.Components.MemberFilterComponent do
{active_boolean_filters_count(@boolean_filters)} {active_boolean_filters_count(@boolean_filters)}
</span> </span>
<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" class="badge badge-primary badge-sm"
> >
{@member_count} {@member_count}
@ -103,7 +119,7 @@ defmodule MvWeb.Components.MemberFilterComponent do
role="dialog" role="dialog"
aria-label={gettext("Member filter")} 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 --> <!-- Payment Filter Group -->
<div class="mb-4"> <div class="mb-4">
<div class="text-xs font-semibold opacity-70 mb-2 uppercase tracking-wider"> <div class="text-xs font-semibold opacity-70 mb-2 uppercase tracking-wider">
@ -162,6 +178,73 @@ defmodule MvWeb.Components.MemberFilterComponent do
</fieldset> </fieldset>
</div> </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 --> <!-- Custom Fields Group -->
<div :if={length(@boolean_custom_fields) > 0} class="mb-2"> <div :if={length(@boolean_custom_fields) > 0} class="mb-2">
<div class="text-xs font-semibold opacity-70 mb-2 uppercase tracking-wider"> <div class="text-xs font-semibold opacity-70 mb-2 uppercase tracking-wider">
@ -274,6 +357,16 @@ defmodule MvWeb.Components.MemberFilterComponent do
_ -> nil _ -> nil
end 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") # Parse boolean custom field filters (including nil values for "all")
custom_boolean_filters_parsed = custom_boolean_filters_parsed =
params params
@ -288,6 +381,21 @@ defmodule MvWeb.Components.MemberFilterComponent do
send(self(), {:payment_filter_changed, payment_filter}) send(self(), {:payment_filter_changed, payment_filter})
end 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 # Update boolean filters - send events for each changed filter
current_filters = socket.assigns.boolean_filters current_filters = socket.assigns.boolean_filters
@ -310,7 +418,7 @@ defmodule MvWeb.Components.MemberFilterComponent do
def handle_event("reset_filters", _params, socket) do def handle_event("reset_filters", _params, socket) do
# Send single message to reset all filters at once (performance optimization) # Send single message to reset all filters at once (performance optimization)
# This avoids N×2 load_members() calls when resetting multiple filters # 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 # Close dropdown after reset
{:noreply, assign(socket, :open, false)} {: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("all"), do: nil
defp parse_tri_state(_), 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 # Get display label for button
defp button_label(cycle_status_filter, boolean_custom_fields, boolean_filters) do defp button_label(
# If payment filter is active, show payment filter label cycle_status_filter,
if cycle_status_filter do groups,
payment_filter_label(cycle_status_filter) group_filters,
else boolean_custom_fields,
# Otherwise show boolean filter labels boolean_filters
boolean_filter_label(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
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 # Get payment filter label
defp payment_filter_label(nil), do: gettext("All") defp payment_filter_label(nil), do: gettext("All")
defp payment_filter_label(:paid), do: gettext("Paid") defp payment_filter_label(:paid), do: gettext("Paid")
@ -406,6 +546,39 @@ defmodule MvWeb.Components.MemberFilterComponent do
end end
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 # Get CSS classes for boolean filter label based on current state
defp boolean_filter_label_class(boolean_filters, custom_field_id, expected_value) do defp boolean_filter_label_class(boolean_filters, custom_field_id, expected_value) do
base_classes = "join-item btn btn-sm" 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() @custom_field_prefix Mv.Constants.custom_field_prefix()
@boolean_filter_prefix Mv.Constants.boolean_filter_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) # Maximum number of boolean custom field filters allowed per request (DoS protection)
@max_boolean_filters Mv.Constants.max_boolean_filters() @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_field, fn -> :first_name end)
|> assign_new(:sort_order, fn -> :asc end) |> assign_new(:sort_order, fn -> :asc end)
|> assign(:cycle_status_filter, nil) |> assign(:cycle_status_filter, nil)
|> assign(:group_filter, nil) |> assign(:group_filters, %{})
|> assign(:groups, groups) |> assign(:groups, groups)
|> assign(:boolean_custom_field_filters, %{}) |> assign(:boolean_custom_field_filters, %{})
|> assign(:selected_members, MapSet.new()) |> assign(:selected_members, MapSet.new())
@ -250,7 +251,7 @@ defmodule MvWeb.MemberLive.Index do
socket.assigns.sort_field, socket.assigns.sort_field,
socket.assigns.sort_order, socket.assigns.sort_order,
socket.assigns.cycle_status_filter, socket.assigns.cycle_status_filter,
socket.assigns[:group_filter], socket.assigns[:group_filters],
new_show_current, new_show_current,
socket.assigns.boolean_custom_field_filters socket.assigns.boolean_custom_field_filters
) )
@ -264,35 +265,6 @@ defmodule MvWeb.MemberLive.Index do
{:noreply, push_patch(socket, to: new_path, replace: true)} {:noreply, push_patch(socket, to: new_path, replace: true)}
end 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 @impl true
def handle_event("copy_emails", _params, socket) do def handle_event("copy_emails", _params, socket) do
selected_ids = socket.assigns.selected_members selected_ids = socket.assigns.selected_members
@ -390,7 +362,7 @@ defmodule MvWeb.MemberLive.Index do
export_sort_field(socket.assigns.sort_field), export_sort_field(socket.assigns.sort_field),
export_sort_order(socket.assigns.sort_order), export_sort_order(socket.assigns.sort_order),
socket.assigns.cycle_status_filter, socket.assigns.cycle_status_filter,
socket.assigns[:group_filter], socket.assigns[:group_filters],
socket.assigns.show_current_cycle, socket.assigns.show_current_cycle,
socket.assigns.boolean_custom_field_filters socket.assigns.boolean_custom_field_filters
) )
@ -416,7 +388,7 @@ defmodule MvWeb.MemberLive.Index do
socket.assigns.sort_field, socket.assigns.sort_field,
socket.assigns.sort_order, socket.assigns.sort_order,
socket.assigns.cycle_status_filter, socket.assigns.cycle_status_filter,
socket.assigns[:group_filter], socket.assigns[:group_filters],
socket.assigns.show_current_cycle, socket.assigns.show_current_cycle,
socket.assigns.boolean_custom_field_filters socket.assigns.boolean_custom_field_filters
) )
@ -444,7 +416,7 @@ defmodule MvWeb.MemberLive.Index do
socket.assigns.sort_field, socket.assigns.sort_field,
socket.assigns.sort_order, socket.assigns.sort_order,
filter, filter,
socket.assigns[:group_filter], socket.assigns[:group_filters],
socket.assigns.show_current_cycle, socket.assigns.show_current_cycle,
socket.assigns.boolean_custom_field_filters socket.assigns.boolean_custom_field_filters
) )
@ -478,7 +450,7 @@ defmodule MvWeb.MemberLive.Index do
socket.assigns.sort_field, socket.assigns.sort_field,
socket.assigns.sort_order, socket.assigns.sort_order,
socket.assigns.cycle_status_filter, socket.assigns.cycle_status_filter,
socket.assigns[:group_filter], socket.assigns[:group_filters],
socket.assigns.show_current_cycle, socket.assigns.show_current_cycle,
updated_filters updated_filters
) )
@ -491,12 +463,55 @@ defmodule MvWeb.MemberLive.Index do
{:noreply, push_patch(socket, to: new_path, replace: true)} {:noreply, push_patch(socket, to: new_path, replace: true)}
end 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 @impl true
def handle_info({:reset_all_filters, cycle_status_filter, boolean_filters}, socket) do 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 =
socket socket
|> assign(:cycle_status_filter, cycle_status_filter) |> assign(:cycle_status_filter, cycle_status_filter)
|> assign(:group_filter, nil) |> assign(:group_filters, group_filters)
|> assign(:boolean_custom_field_filters, boolean_filters) |> assign(:boolean_custom_field_filters, boolean_filters)
|> load_members() |> load_members()
|> update_selection_assigns() |> update_selection_assigns()
@ -507,7 +522,7 @@ defmodule MvWeb.MemberLive.Index do
socket.assigns.sort_field, socket.assigns.sort_field,
socket.assigns.sort_order, socket.assigns.sort_order,
cycle_status_filter, cycle_status_filter,
socket.assigns[:group_filter], socket.assigns[:group_filters],
socket.assigns.show_current_cycle, socket.assigns.show_current_cycle,
boolean_filters boolean_filters
) )
@ -644,7 +659,7 @@ defmodule MvWeb.MemberLive.Index do
|> maybe_update_search(params) |> maybe_update_search(params)
|> maybe_update_sort(params) |> maybe_update_sort(params)
|> maybe_update_cycle_status_filter(params) |> maybe_update_cycle_status_filter(params)
|> maybe_update_group_filter(params) |> maybe_update_group_filters(params)
|> maybe_update_boolean_filters(params) |> maybe_update_boolean_filters(params)
|> maybe_update_show_current_cycle(params) |> maybe_update_show_current_cycle(params)
|> assign(:fields_in_url?, fields_in_url?) |> assign(:fields_in_url?, fields_in_url?)
@ -678,7 +693,7 @@ defmodule MvWeb.MemberLive.Index do
socket.assigns.sort_field, socket.assigns.sort_field,
socket.assigns.sort_order, socket.assigns.sort_order,
socket.assigns.cycle_status_filter, socket.assigns.cycle_status_filter,
socket.assigns[:group_filter], socket.assigns[:group_filters],
socket.assigns.show_current_cycle, socket.assigns.show_current_cycle,
socket.assigns.boolean_custom_field_filters, socket.assigns.boolean_custom_field_filters,
socket.assigns.user_field_selection, socket.assigns.user_field_selection,
@ -772,7 +787,7 @@ defmodule MvWeb.MemberLive.Index do
socket.assigns.sort_field, socket.assigns.sort_field,
socket.assigns.sort_order, socket.assigns.sort_order,
socket.assigns.cycle_status_filter, socket.assigns.cycle_status_filter,
socket.assigns[:group_filter], socket.assigns[:group_filters],
socket.assigns.show_current_cycle, socket.assigns.show_current_cycle,
socket.assigns.boolean_custom_field_filters socket.assigns.boolean_custom_field_filters
) )
@ -791,7 +806,7 @@ defmodule MvWeb.MemberLive.Index do
sort_field, sort_field,
sort_order, sort_order,
cycle_status_filter, cycle_status_filter,
group_filter, group_filters,
show_current_cycle, show_current_cycle,
boolean_filters boolean_filters
) do ) do
@ -823,11 +838,10 @@ defmodule MvWeb.MemberLive.Index do
end end
base_params = base_params =
if group_filter && group_filter != "" do Enum.reduce(group_filters, base_params, fn {group_id_str, value}, acc ->
Map.put(base_params, "group_filter", to_string(group_filter)) param_value = if value == :in, do: "in", else: "not_in"
else Map.put(acc, "#{@group_filter_prefix}#{group_id_str}", param_value)
base_params end)
end
base_params = base_params =
if show_current_cycle do if show_current_cycle do
@ -884,7 +898,7 @@ defmodule MvWeb.MemberLive.Index do
query = apply_search_filter(query, search_query) 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) # Use ALL custom fields for sorting (not just show_in_overview subset)
custom_fields_for_sort = socket.assigns.all_custom_fields custom_fields_for_sort = socket.assigns.all_custom_fields
@ -963,13 +977,50 @@ defmodule MvWeb.MemberLive.Index do
end end
end end
defp apply_group_filter(query, nil), do: query defp apply_group_filters(query, group_filters, _groups) when group_filters == %{}, do: query
defp apply_group_filter(query, ""), do: query
defp apply_group_filter(query, group_id) when is_binary(group_id) do defp apply_group_filters(query, group_filters, groups) do
Ash.Query.filter(query, expr(exists(groups, id == ^group_id))) 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 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, nil, _show_current), do: members
defp apply_cycle_status_filter(members, status, show_current) defp apply_cycle_status_filter(members, status, show_current)
@ -1097,17 +1148,15 @@ defmodule MvWeb.MemberLive.Index do
end end
defp sort_members_in_memory(members, field, order, custom_fields) do defp sort_members_in_memory(members, field, order, custom_fields) do
cond do if field in [:groups, "groups"] do
field in [:groups, "groups"] -> sort_members_by_groups(members, order)
sort_members_by_groups(members, order) else
custom_field_id_str = extract_custom_field_id(field)
true -> case custom_field_id_str do
custom_field_id_str = extract_custom_field_id(field) nil -> members
id_str -> sort_members_by_custom_field(members, id_str, order, custom_fields)
case custom_field_id_str do end
nil -> members
id_str -> sort_members_by_custom_field(members, id_str, order, custom_fields)
end
end 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 defp determine_field_after_computed_check(default, sf) when is_binary(sf) do
cond do cond do
sf == "groups" -> :groups sf == "groups" ->
custom_field_sort?(sf) -> if valid_sort_field?(sf), do: sf, else: default :groups
custom_field_sort?(sf) ->
if valid_sort_field?(sf), do: sf, else: default
true -> true ->
atom = safe_member_field_atom_only(sf) atom = safe_member_field_atom_only(sf)
if atom != nil and valid_sort_field?(atom), do: atom, else: default 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), defp maybe_update_cycle_status_filter(socket, _params),
do: assign(socket, :cycle_status_filter, nil) do: assign(socket, :cycle_status_filter, nil)
defp maybe_update_group_filter(socket, %{"group_filter" => group_id_param}) do defp maybe_update_group_filters(socket, params) when is_map(params) do
group_filter = normalize_group_filter(group_id_param, socket.assigns.groups) prefix = @group_filter_prefix
assign(socket, :group_filter, group_filter) 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 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 add_group_filter_entry(acc, key, value_str, prefix_len) do
defp normalize_group_filter(nil, _groups), do: nil 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 if valid_id? do
case Ecto.UUID.cast(group_id_param) do case parse_group_filter_value(value_str) do
{:ok, _uuid} -> nil -> acc
if Enum.any?(groups, fn g -> to_string(g.id) == group_id_param end) do value -> Map.put(acc, group_id_str, value)
group_id_param end
else else
nil acc
end
:error ->
nil
end end
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("paid"), do: :paid
defp determine_cycle_status_filter("unpaid"), do: :unpaid defp determine_cycle_status_filter("unpaid"), do: :unpaid

View file

@ -52,26 +52,12 @@
query={@query} query={@query}
placeholder={gettext("Search...")} 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 <.live_component
module={MvWeb.Components.MemberFilterComponent} module={MvWeb.Components.MemberFilterComponent}
id="member-filter" id="member-filter"
cycle_status_filter={@cycle_status_filter} cycle_status_filter={@cycle_status_filter}
groups={@groups}
group_filters={@group_filters}
boolean_custom_fields={@boolean_custom_fields} boolean_custom_fields={@boolean_custom_fields}
boolean_filters={@boolean_custom_field_filters} boolean_filters={@boolean_custom_field_filters}
member_count={length(@members)} member_count={length(@members)}

View file

@ -2191,7 +2191,9 @@ msgid "Group saved successfully."
msgstr "Gruppe erfolgreich gespeichert." msgstr "Gruppe erfolgreich gespeichert."
#: lib/mv_web/components/layouts/sidebar.ex #: lib/mv_web/components/layouts/sidebar.ex
#: lib/mv_web/live/components/member_filter_component.ex
#: lib/mv_web/live/group_live/index.ex #: lib/mv_web/live/group_live/index.ex
#: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Groups" msgid "Groups"
msgstr "Gruppen" msgstr "Gruppen"
@ -2468,22 +2470,7 @@ msgstr "Pausiert"
msgid "unpaid" msgid "unpaid"
msgstr "Unbezahlt" msgstr "Unbezahlt"
#~ #: lib/mv_web/live/global_settings_live.ex #: lib/mv_web/live/member_live/index.html.heex
#~ #, elixir-autogen, elixir-format, fuzzy #, elixir-autogen, elixir-format
#~ msgid "Custom Fields in CSV Import" msgid "Member of group %{name}"
#~ msgstr "Benutzerdefinierte Felder" msgstr "Mitglied der Gruppe %{name}"
#~ #: lib/mv_web/live/global_settings_live.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Failed to prepare CSV import: %{error}"
#~ msgstr "Das Vorbereiten des CSV Imports ist gescheitert: %{error}"
#~ #: lib/mv_web/live/global_settings_live.ex
#~ #, elixir-autogen, elixir-format, fuzzy
#~ msgid "Individual data fields must be created in Mila before importing. Use the field name as the CSV column header. Unknown custom field columns will be ignored with a warning."
#~ msgstr "Individuelle Datenfelder müssen in Mila erstellt werden, bevor sie importiert werden können. Verwende den Namen des Datenfeldes als CSV-Spaltenüberschrift. Unbekannte Spaltenüberschriften werden mit einer Warnung ignoriert."
#~ #: lib/mv_web/live/member_live/show/membership_fees_component.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Only administrators can regenerate cycles"
#~ msgstr "Nur Administrator*innen können Zyklen regenerieren"

View file

@ -2192,7 +2192,9 @@ msgid "Group saved successfully."
msgstr "" msgstr ""
#: lib/mv_web/components/layouts/sidebar.ex #: lib/mv_web/components/layouts/sidebar.ex
#: lib/mv_web/live/components/member_filter_component.ex
#: lib/mv_web/live/group_live/index.ex #: lib/mv_web/live/group_live/index.ex
#: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Groups" msgid "Groups"
msgstr "" msgstr ""
@ -2468,3 +2470,8 @@ msgstr ""
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "unpaid" msgid "unpaid"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "Member of group %{name}"
msgstr ""

View file

@ -13,6 +13,7 @@ msgstr ""
#: lib/mv_web/components/core_components.ex #: lib/mv_web/components/core_components.ex
#: lib/mv_web/live/group_live/index.ex #: lib/mv_web/live/group_live/index.ex
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Actions" msgid "Actions"
msgstr "" msgstr ""
@ -671,6 +672,7 @@ msgstr ""
msgid "A member with this email already exists. To link with a different member, please change one of the email addresses first." msgid "A member with this email already exists. To link with a different member, please change one of the email addresses first."
msgstr "" msgstr ""
#: lib/mv_web/live/group_live/show.ex
#: lib/mv_web/live/user_live/form.ex #: lib/mv_web/live/user_live/form.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Available members" msgid "Available members"
@ -2190,7 +2192,9 @@ msgid "Group saved successfully."
msgstr "" msgstr ""
#: lib/mv_web/components/layouts/sidebar.ex #: lib/mv_web/components/layouts/sidebar.ex
#: lib/mv_web/live/components/member_filter_component.ex
#: lib/mv_web/live/group_live/index.ex #: lib/mv_web/live/group_live/index.ex
#: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Groups" msgid "Groups"
msgstr "" msgstr ""
@ -2254,6 +2258,66 @@ msgstr ""
msgid "Could not load member search. Please try again." msgid "Could not load member search. Please try again."
msgstr "" msgstr ""
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Add Member"
msgstr ""
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Failed to remove member: %{error}"
msgstr ""
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format
msgid "Member is not in this group."
msgstr ""
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "No email"
msgstr ""
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format
msgid "Remove"
msgstr ""
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format
msgid "Remove member from group"
msgstr ""
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Search for a member"
msgstr ""
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Search for a member..."
msgstr ""
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format
msgid "Add members"
msgstr ""
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "No members selected."
msgstr ""
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format
msgid "Remove %{name}"
msgstr ""
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format
msgid "Some members could not be added: %{errors}"
msgstr ""
#: lib/mv_web/live/import_export_live/components.ex #: lib/mv_web/live/import_export_live/components.ex
#, elixir-autogen, elixir-format, fuzzy #, elixir-autogen, elixir-format, fuzzy
msgid "CSV files only, maximum %{size} MB" msgid "CSV files only, maximum %{size} MB"
@ -2407,22 +2471,7 @@ msgstr ""
msgid "unpaid" msgid "unpaid"
msgstr "" msgstr ""
#~ #: lib/mv_web/live/global_settings_live.ex #: lib/mv_web/live/member_live/index.html.heex
#~ #, elixir-autogen, elixir-format, fuzzy #, elixir-autogen, elixir-format
#~ msgid "Custom Fields in CSV Import" msgid "Member of group %{name}"
#~ msgstr "" msgstr ""
#~ #: lib/mv_web/live/global_settings_live.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Failed to prepare CSV import: %{error}"
#~ msgstr ""
#~ #: lib/mv_web/live/global_settings_live.ex
#~ #, elixir-autogen, elixir-format, fuzzy
#~ msgid "Individual data fields must be created in Mila before importing. Use the field name as the CSV column header. Unknown custom field columns will be ignored with a warning."
#~ msgstr ""
#~ #: lib/mv_web/live/member_live/show/membership_fees_component.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Only administrators can regenerate cycles"
#~ msgstr ""

View file

@ -62,18 +62,21 @@ defmodule MvWeb.MemberLive.IndexGroupsAccessibilityTest do
end end
@tag :ui @tag :ui
test "filter dropdown has aria-label", %{ test "filter dropdown has group presence section with legend", %{
conn: conn conn: conn
} do } do
conn = conn_with_oidc_user(conn) conn = conn_with_oidc_user(conn)
{:ok, view, html} = live(conn, "/members") {:ok, view, _html} = live(conn, "/members")
# Verify filter dropdown has aria-label # Open filter dropdown
assert html =~ ~r/select.*name=["']group_filter["'].*aria-label=/ or view
html =~ ~r/aria-label=.*[Gg]roup/ |> element("button[aria-label='Filter members']")
|> render_click()
# Verify dropdown is present html = render(view)
assert has_element?(view, "select[name='group_filter']") # Groups section: legend "Member has groups" and radios (Any / Yes / No)
assert html =~ ~r/[Gg]roups/
assert has_element?(view, "[data-testid='member-filter-form']")
end end
@tag :ui @tag :ui
@ -92,26 +95,22 @@ defmodule MvWeb.MemberLive.IndexGroupsAccessibilityTest do
@tag :ui @tag :ui
test "keyboard navigation works for filter dropdown", %{ test "keyboard navigation works for filter dropdown", %{
conn: conn, conn: conn,
member1: member1,
group1: group1 group1: group1
} do } do
conn = conn_with_oidc_user(conn) conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members") {:ok, view, _html} = live(conn, "/members")
# Verify dropdown is keyboard accessible
# Tab should focus the dropdown
# Arrow keys should navigate options
# Enter should select option
assert has_element?(view, "select[name='group_filter']")
# Test that dropdown can be focused and changed via keyboard
# (This is a basic accessibility check - actual keyboard testing would require browser automation)
view view
|> element("#group-filter-form") |> element("button[aria-label='Filter members']")
|> render_change(%{"group_filter" => group1.id}) |> render_click()
view
|> element("[data-testid='member-filter-form']")
|> render_change(%{"group_#{group1.id}" => "in", "payment_filter" => "all"})
# Verify change was applied
html = render(view) html = render(view)
assert html assert html =~ member1.first_name
end end
@tag :ui @tag :ui
@ -121,18 +120,14 @@ defmodule MvWeb.MemberLive.IndexGroupsAccessibilityTest do
conn = conn_with_oidc_user(conn) conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members") {:ok, view, _html} = live(conn, "/members")
# Verify sort header is keyboard accessible
# Tab should focus the sort header
# Enter/Space should activate sorting
assert has_element?(view, "[data-testid='groups']") assert has_element?(view, "[data-testid='groups']")
# Test that sort header can be activated via click (simulating keyboard)
view view
|> element("[data-testid='groups']") |> element("[data-testid='groups']")
|> render_click() |> render_click()
# Verify sort was applied # Verify sort was applied (URL may include other params)
assert_patch(view, "/members?query=&sort_field=groups&sort_order=asc") assert has_element?(view, "[data-testid='groups'][aria-label*='ascending']")
end end
@tag :ui @tag :ui
@ -144,19 +139,16 @@ defmodule MvWeb.MemberLive.IndexGroupsAccessibilityTest do
conn = conn_with_oidc_user(conn) conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members") {:ok, view, _html} = live(conn, "/members")
# Apply filter
view view
|> element("#group-filter-form") |> element("button[aria-label='Filter members']")
|> render_change(%{"group_filter" => group1.id}) |> render_click()
view
|> element("[data-testid='member-filter-form']")
|> render_change(%{"group_#{group1.id}" => "in", "payment_filter" => "all"})
# Verify filter change is announced (via aria-live region or similar)
html = render(view) html = render(view)
# Should show filtered results
assert html =~ member1.first_name assert html =~ member1.first_name
# Verify member count or filter status is announced
# (Implementation might use aria-live="polite" for announcements)
assert html
end end
@tag :ui @tag :ui

View file

@ -64,7 +64,11 @@ defmodule MvWeb.MemberLive.IndexGroupsDisplayTest do
%{member1: member1, member2: member2, member3: member3, group1: group1, group2: group2} %{member1: member1, member2: member2, member3: member3, group1: group1, group2: group2}
end end
test "displays group badges for members in groups", %{conn: conn, group1: group1, group2: group2} do test "displays group badges for members in groups", %{
conn: conn,
group1: group1,
group2: group2
} do
conn = conn_with_oidc_user(conn) conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members") {:ok, _view, html} = live(conn, "/members")

View file

@ -1,6 +1,9 @@
defmodule MvWeb.MemberLive.IndexGroupsFilterTest do defmodule MvWeb.MemberLive.IndexGroupsFilterTest do
@moduledoc """ @moduledoc """
Tests for filtering members by group in the member overview. Tests for filtering members by group in the member overview.
Uses the filter dropdown (MemberFilterComponent) with one row per group:
All / Yes / No (per group).
""" """
# async: false to prevent PostgreSQL deadlocks when creating members and groups # async: false to prevent PostgreSQL deadlocks when creating members and groups
use MvWeb.ConnCase, async: false use MvWeb.ConnCase, async: false
@ -53,7 +56,28 @@ defmodule MvWeb.MemberLive.IndexGroupsFilterTest do
%{member1: member1, member2: member2, member3: member3, group1: group1, group2: group2} %{member1: member1, member2: member2, member3: member3, group1: group1, group2: group2}
end end
test "filter 'All groups' shows all members", %{conn: conn, member1: m1, member2: m2, member3: m3} do defp open_filter_and_set_group(view, group_id, value) do
view
|> element("button[aria-label='Filter members']")
|> render_click()
key = "group_#{group_id}"
view
|> element("[data-testid='member-filter-form']")
|> render_change(%{key => value, "payment_filter" => "all"})
# Force LiveView to process {:group_filter_changed, ...} (render triggers mailbox processing)
_ = render(view)
assert_patch(view)
end
test "filter All (default) shows all members", %{
conn: conn,
member1: m1,
member2: m2,
member3: m3
} do
conn = conn_with_oidc_user(conn) conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members") {:ok, _view, html} = live(conn, "/members")
assert html =~ m1.first_name assert html =~ m1.first_name
@ -61,7 +85,7 @@ defmodule MvWeb.MemberLive.IndexGroupsFilterTest do
assert html =~ m3.first_name assert html =~ m3.first_name
end end
test "filter by specific group shows only members in that group", %{ test "filter group1 Yes shows only members in group1", %{
conn: conn, conn: conn,
member1: m1, member1: m1,
member2: m2, member2: m2,
@ -71,9 +95,7 @@ defmodule MvWeb.MemberLive.IndexGroupsFilterTest do
conn = conn_with_oidc_user(conn) conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members") {:ok, view, _html} = live(conn, "/members")
view open_filter_and_set_group(view, group1.id, "in")
|> element("#group-filter-form")
|> render_change(%{"group_filter" => group1.id})
html = render(view) html = render(view)
assert html =~ m1.first_name assert html =~ m1.first_name
@ -81,21 +103,45 @@ defmodule MvWeb.MemberLive.IndexGroupsFilterTest do
refute html =~ m3.first_name refute html =~ m3.first_name
end end
test "filter persists in URL parameters", %{conn: conn, group1: group1, member1: m1} do test "filter group1 No shows only members not in group1", %{
conn: conn,
member1: m1,
member2: m2,
member3: m3,
group1: group1
} do
conn = conn_with_oidc_user(conn) conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members") {:ok, view, _html} = live(conn, "/members")
view open_filter_and_set_group(view, group1.id, "not_in")
|> element("#group-filter-form")
|> render_change(%{"group_filter" => group1.id}) html = render(view)
refute html =~ m1.first_name
assert html =~ m2.first_name
assert html =~ m3.first_name
end
test "filter persists in URL parameters", %{
conn: conn,
member1: m1,
member2: m2,
member3: m3,
group1: group1
} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
open_filter_and_set_group(view, group1.id, "in")
# Verify filter is applied
html = render(view) html = render(view)
assert html =~ m1.first_name assert html =~ m1.first_name
refute html =~ m2.first_name
refute html =~ m3.first_name
# Verify visiting with group_filter in URL shows same filtered list {:ok, _view2, html2} = live(conn, "/members?group_#{group1.id}=in")
{:ok, _view2, html2} = live(conn, "/members?group_filter=#{group1.id}")
assert html2 =~ m1.first_name assert html2 =~ m1.first_name
refute html2 =~ m2.first_name
refute html2 =~ m3.first_name
end end
test "filter is restored from URL on load", %{ test "filter is restored from URL on load", %{
@ -106,7 +152,7 @@ defmodule MvWeb.MemberLive.IndexGroupsFilterTest do
group1: group1 group1: group1
} do } do
conn = conn_with_oidc_user(conn) conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members?group_filter=#{group1.id}") {:ok, _view, html} = live(conn, "/members?group_#{group1.id}=in")
assert html =~ m1.first_name assert html =~ m1.first_name
refute html =~ m2.first_name refute html =~ m2.first_name
refute html =~ m3.first_name refute html =~ m3.first_name

View file

@ -102,8 +102,12 @@ defmodule MvWeb.MemberLive.IndexGroupsIntegrationTest do
{:ok, view, _html} = live(conn, "/members") {:ok, view, _html} = live(conn, "/members")
view view
|> element("#group-filter-form") |> element("button[aria-label='Filter members']")
|> render_change(%{"group_filter" => group1.id}) |> render_click()
view
|> element("[data-testid='member-filter-form']")
|> render_change(%{"group_#{group1.id}" => "in", "payment_filter" => "all"})
html = render(view) html = render(view)
assert html =~ member1.first_name assert html =~ member1.first_name
@ -160,13 +164,13 @@ defmodule MvWeb.MemberLive.IndexGroupsIntegrationTest do
|> Ash.create(actor: system_actor) |> Ash.create(actor: system_actor)
conn = conn_with_oidc_user(conn) conn = conn_with_oidc_user(conn)
# Visit with both group filter and cycle status filter in URL (cycle filter is toggled via button, not a select).
# Cycle filter may depend on "current" cycle; we only verify the page loads with both params.
{:ok, _view, html} = {:ok, _view, html} =
live(conn, "/members?group_filter=#{group1.id}&cycle_status_filter=paid") live(conn, "/members?group_#{group1.id}=in&cycle_status_filter=paid")
assert html =~ "Members" assert html =~ "Members"
assert html =~ group1.name # member1 has a group and a paid cycle; page should load with both filters
assert html =~ member1.first_name
end end
test "groups work with existing search (not testing search integration)", %{ test "groups work with existing search (not testing search integration)", %{
@ -180,8 +184,12 @@ defmodule MvWeb.MemberLive.IndexGroupsIntegrationTest do
# Apply group filter # Apply group filter
view view
|> element("#group-filter-form") |> element("button[aria-label='Filter members']")
|> render_change(%{"group_filter" => group1.id}) |> render_click()
view
|> element("[data-testid='member-filter-form']")
|> render_change(%{"group_#{group1.id}" => "in", "payment_filter" => "all"})
# Apply search (this tests that filter and search work together; # Apply search (this tests that filter and search work together;
# search form is in SearchBarComponent with phx-submit="search") # search form is in SearchBarComponent with phx-submit="search")
@ -208,8 +216,12 @@ defmodule MvWeb.MemberLive.IndexGroupsIntegrationTest do
# Apply group filter # Apply group filter
view view
|> element("#group-filter-form") |> element("button[aria-label='Filter members']")
|> render_change(%{"group_filter" => group1.id}) |> render_click()
view
|> element("[data-testid='member-filter-form']")
|> render_change(%{"group_#{group1.id}" => "in", "payment_filter" => "all"})
# Apply sorting # Apply sorting
view view

View file

@ -97,30 +97,28 @@ defmodule MvWeb.MemberLive.IndexGroupsPerformanceTest do
conn = conn_with_oidc_user(conn) conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members") {:ok, view, _html} = live(conn, "/members")
# Apply filter # Open filter and apply "Yes" for group1 (even-indexed members are in group1)
view view
|> element("#group-filter-form") |> element("button[aria-label='Filter members']")
|> render_change(%{"group_filter" => group1.id}) |> render_click()
# Verify only filtered members are shown view
|> element("[data-testid='member-filter-form']")
|> render_change(%{"group_#{group1.id}" => "in", "payment_filter" => "all"})
# Force LiveView to process {:group_filter_changed, ...}
html = render(view) html = render(view)
# Members with even indices (0, 2, 4, 6, 8) are in group1 # Only even-indexed members (0,2,4,6,8) are in group1
even_members = Enum.filter(0..9, &(rem(&1, 2) == 0)) Enum.each([0, 2, 4, 6, 8], fn i ->
odd_members = Enum.filter(0..9, &(rem(&1, 2) == 1))
Enum.each(even_members, fn i ->
member = Enum.at(members, i) member = Enum.at(members, i)
assert html =~ member.first_name assert html =~ member.first_name
end) end)
Enum.each(odd_members, fn i -> Enum.each([1, 3, 5, 7, 9], fn i ->
member = Enum.at(members, i) member = Enum.at(members, i)
refute html =~ member.first_name refute html =~ member.first_name
end) end)
# If filtering was done in-memory, we'd load all members first
# Database-level filtering is more efficient
end end
@tag :slow @tag :slow

View file

@ -3,7 +3,7 @@ defmodule MvWeb.MemberLive.IndexGroupsUrlParamsTest do
Tests for URL parameter persistence for groups in the member overview. Tests for URL parameter persistence for groups in the member overview.
Tests cover: Tests cover:
- Group filter is written to URL (group_filter=<group_id>) - Group presence filter is written to URL (group_presence=has_groups|no_groups)
- Group sorting is written to URL (sort_field=groups&sort_order=asc) - Group sorting is written to URL (sort_field=groups&sort_order=asc)
- URL parameters are restored on load - URL parameters are restored on load
- URL parameters work with other parameters (query, sort_field, etc.) - URL parameters work with other parameters (query, sort_field, etc.)
@ -53,19 +53,22 @@ defmodule MvWeb.MemberLive.IndexGroupsUrlParamsTest do
test "group filter is written to URL", %{ test "group filter is written to URL", %{
conn: conn, conn: conn,
member1: member1,
group1: group1 group1: group1
} do } do
conn = conn_with_oidc_user(conn) conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members") {:ok, view, _html} = live(conn, "/members")
# Apply group filter
view view
|> element("#group-filter-form") |> element("button[aria-label='Filter members']")
|> render_change(%{"group_filter" => group1.id}) |> render_click()
view
|> element("[data-testid='member-filter-form']")
|> render_change(%{"group_#{group1.id}" => "in", "payment_filter" => "all"})
# Verify filter was applied (URL is patched with group_filter and other default params)
html = render(view) html = render(view)
assert html =~ group1.name assert html =~ member1.first_name
end end
test "group sorting is written to URL", %{ test "group sorting is written to URL", %{
@ -92,13 +95,10 @@ defmodule MvWeb.MemberLive.IndexGroupsUrlParamsTest do
conn = conn_with_oidc_user(conn) conn = conn_with_oidc_user(conn)
{:ok, view, html} = {:ok, view, html} =
live(conn, "/members?group_filter=#{group1.id}&sort_field=groups&sort_order=asc") live(conn, "/members?group_#{group1.id}=in&sort_field=groups&sort_order=asc")
# Verify filter is applied
assert html =~ member1.first_name assert html =~ member1.first_name
refute html =~ member2.first_name refute html =~ member2.first_name
# Verify sort is applied
assert has_element?(view, "[data-testid='groups'][aria-label*='ascending']") assert has_element?(view, "[data-testid='groups'][aria-label*='ascending']")
end end
@ -108,23 +108,22 @@ defmodule MvWeb.MemberLive.IndexGroupsUrlParamsTest do
group1: group1 group1: group1
} do } do
conn = conn_with_oidc_user(conn) conn = conn_with_oidc_user(conn)
{:ok, view, html} = live(conn, "/members?query=Alice&group_filter=#{group1.id}") {:ok, _view, html} = live(conn, "/members?query=Alice&group_#{group1.id}=in")
# Verify both query and filter are applied (URL may include other default params)
assert html =~ member1.first_name assert html =~ member1.first_name
end end
test "URL parameters work with other sort fields", %{ test "URL parameters work with other sort fields", %{
conn: conn, conn: conn,
member1: member1,
group1: group1 group1: group1
} do } do
conn = conn_with_oidc_user(conn) conn = conn_with_oidc_user(conn)
{:ok, view, html} = {:ok, view, html} =
live(conn, "/members?sort_field=first_name&sort_order=desc&group_filter=#{group1.id}") live(conn, "/members?sort_field=first_name&sort_order=desc&group_#{group1.id}=in")
# Verify all parameters are preserved (filter applied, sort reflected in UI) assert html =~ member1.first_name
assert html =~ group1.name
assert has_element?(view, "[data-testid='first_name'][aria-label*='descending']") assert has_element?(view, "[data-testid='first_name'][aria-label*='descending']")
end end
@ -134,37 +133,28 @@ defmodule MvWeb.MemberLive.IndexGroupsUrlParamsTest do
group1: group1 group1: group1
} do } do
conn = conn_with_oidc_user(conn) conn = conn_with_oidc_user(conn)
# Simulate bookmarking a URL with filter and sort bookmark_url = "/members?group_#{group1.id}=in&sort_field=groups&sort_order=asc"
bookmark_url = "/members?group_filter=#{group1.id}&sort_field=groups&sort_order=asc"
{:ok, view, html} = live(conn, bookmark_url) {:ok, view, html} = live(conn, bookmark_url)
# Verify filter and sort are both applied when loading bookmarked URL
assert html =~ member1.first_name assert html =~ member1.first_name
assert has_element?(view, "[data-testid='groups'][aria-label*='ascending']") assert has_element?(view, "[data-testid='groups'][aria-label*='ascending']")
end end
test "handles multiple group_filter parameters (uses last one)", %{ test "handles multiple group filter parameters (uses last one)", %{
conn: conn, conn: conn,
member1: member1,
member2: member2,
group1: group1 group1: group1
} do } do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
{:ok, group2} =
Group
|> Ash.Changeset.for_create(:create, %{name: "Active Members"})
|> Ash.create(actor: system_actor)
conn = conn_with_oidc_user(conn) conn = conn_with_oidc_user(conn)
# URL with duplicate parameters (should use last one) # Duplicate param for same group: last wins. group_id=in then not_in -> not_in
{:ok, view, _html} = {:ok, _view, html} =
live(conn, "/members?group_filter=#{group1.id}&group_filter=#{group2.id}") live(conn, "/members?group_#{group1.id}=in&group_#{group1.id}=not_in")
# Verify the last filter value is used # not_in group1: member2 and member3 (member1 is in group1)
# Implementation should handle this gracefully refute html =~ member1.first_name
html = render(view) assert html =~ member2.first_name
# Should show members from group2 (last filter)
assert html
end end
test "handles invalid URL parameters gracefully", %{ test "handles invalid URL parameters gracefully", %{
@ -173,11 +163,10 @@ defmodule MvWeb.MemberLive.IndexGroupsUrlParamsTest do
member2: member2 member2: member2
} do } do
conn = conn_with_oidc_user(conn) conn = conn_with_oidc_user(conn)
# URL with invalid group_filter (non-existent UUID)
invalid_id = Ecto.UUID.generate() invalid_id = Ecto.UUID.generate()
{:ok, view, html} = live(conn, "/members?group_filter=#{invalid_id}") {:ok, _view, html} = live(conn, "/members?group_#{invalid_id}=in")
# Verify all members are shown (invalid filter ignored) # Unknown group id ignored, all members shown
assert html =~ member1.first_name assert html =~ member1.first_name
assert html =~ member2.first_name assert html =~ member2.first_name
end end
@ -188,10 +177,8 @@ defmodule MvWeb.MemberLive.IndexGroupsUrlParamsTest do
member2: member2 member2: member2
} do } do
conn = conn_with_oidc_user(conn) conn = conn_with_oidc_user(conn)
# URL with malformed group_filter (not a UUID) {:ok, _view, html} = live(conn, "/members?group_not-a-uuid=in")
{:ok, view, html} = live(conn, "/members?group_filter=not-a-uuid")
# Verify all members are shown (malformed filter ignored)
assert html =~ member1.first_name assert html =~ member1.first_name
assert html =~ member2.first_name assert html =~ member2.first_name
end end