This commit is contained in:
parent
3322efcdf6
commit
5fd7c0e7f6
13 changed files with 583 additions and 258 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)}
|
||||||
|
|
|
||||||
|
|
@ -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"
|
|
||||||
|
|
|
||||||
|
|
@ -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 ""
|
||||||
|
|
|
||||||
|
|
@ -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 ""
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue