From 5fd7c0e7f63447f67870a092a45b69a06e73b20b Mon Sep 17 00:00:00 2001 From: Simon Date: Fri, 13 Feb 2026 17:45:51 +0100 Subject: [PATCH] feat: improve groups fillter --- docs/feature-roadmap.md | 1 + .../components/member_filter_component.ex | 197 +++++++++++++- lib/mv_web/live/member_live/index.ex | 245 ++++++++++++------ lib/mv_web/live/member_live/index.html.heex | 18 +- priv/gettext/de/LC_MESSAGES/default.po | 25 +- priv/gettext/default.pot | 7 + priv/gettext/en/LC_MESSAGES/default.po | 87 +++++-- .../index_groups_accessibility_test.exs | 60 ++--- .../member_live/index_groups_display_test.exs | 6 +- .../member_live/index_groups_filter_test.exs | 72 ++++- .../index_groups_integration_test.exs | 32 ++- .../index_groups_performance_test.exs | 24 +- .../index_groups_url_params_test.exs | 67 ++--- 13 files changed, 583 insertions(+), 258 deletions(-) diff --git a/docs/feature-roadmap.md b/docs/feature-roadmap.md index 7e28eea..1721139 100644 --- a/docs/feature-roadmap.md +++ b/docs/feature-roadmap.md @@ -85,6 +85,7 @@ - Many-to-many relationship with groups - Groups management UI (`/groups`) - 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_=in|not_in` - 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) - Member field import diff --git a/lib/mv_web/live/components/member_filter_component.ex b/lib/mv_web/live/components/member_filter_component.ex index 9286ace..344a0c2 100644 --- a/lib/mv_web/live/components/member_filter_component.ex +++ b/lib/mv_web/live/components/member_filter_component.ex @@ -16,6 +16,8 @@ defmodule MvWeb.Components.MemberFilterComponent do ## Props - `:cycle_status_filter` - Current payment filter state: `nil` (all), `:paid`, or `:unpaid` + - `:groups` - List of groups (for per-group filter rows) + - `:group_filters` - Map of active group filters: `%{group_id => :in | :not_in}` (nil = All for that group) - `:boolean_custom_fields` - List of boolean custom fields to display - `:boolean_filters` - Map of active boolean filters: `%{custom_field_id => true | false}` - `:id` - Component ID (required) @@ -23,6 +25,7 @@ defmodule MvWeb.Components.MemberFilterComponent do ## Events - Sends `{:payment_filter_changed, filter}` to parent when payment filter changes + - Sends `{:group_filter_changed, group_id_str, value}` to parent when a group filter changes (value: nil | :in | :not_in) - Sends `{:boolean_filter_changed, custom_field_id, filter_value}` to parent when boolean filter changes """ use MvWeb, :live_component @@ -38,6 +41,8 @@ defmodule MvWeb.Components.MemberFilterComponent do socket |> assign(:id, assigns.id) |> assign(:cycle_status_filter, assigns[:cycle_status_filter]) + |> assign(:groups, assigns[:groups] || []) + |> assign(:group_filters, assigns[:group_filters] || %{}) |> assign(:boolean_custom_fields, assigns[:boolean_custom_fields] || []) |> assign(:boolean_filters, assigns[:boolean_filters] || %{}) |> assign(:member_count, assigns[:member_count] || 0) @@ -60,7 +65,9 @@ defmodule MvWeb.Components.MemberFilterComponent do tabindex="0" class={[ "btn gap-2", - (@cycle_status_filter || active_boolean_filters_count(@boolean_filters) > 0) && "btn-active" + (@cycle_status_filter || map_size(@group_filters) > 0 || + active_boolean_filters_count(@boolean_filters) > 0) && + "btn-active" ]} phx-click="toggle_dropdown" phx-target={@myself} @@ -70,7 +77,13 @@ defmodule MvWeb.Components.MemberFilterComponent do > <.icon name="hero-funnel" class="h-5 w-5" /> 0} @@ -79,7 +92,10 @@ defmodule MvWeb.Components.MemberFilterComponent do {active_boolean_filters_count(@boolean_filters)} 0) && + active_boolean_filters_count(@boolean_filters) == 0 + } class="badge badge-primary badge-sm" > {@member_count} @@ -103,7 +119,7 @@ defmodule MvWeb.Components.MemberFilterComponent do role="dialog" aria-label={gettext("Member filter")} > -
+
@@ -162,6 +178,73 @@ defmodule MvWeb.Components.MemberFilterComponent do
+ +
0} class="mb-4"> +
+ {gettext("Groups")} +
+
+
+ + {group.name} + +
+ + + +
+
+
+
+
0} class="mb-2">
@@ -274,6 +357,16 @@ defmodule MvWeb.Components.MemberFilterComponent do _ -> nil end + # Parse per-group filters (params keys "group_" => "all"|"in"|"not_in") + group_filters_parsed = + params + |> Enum.filter(fn {key, _} -> String.starts_with?(key, "group_") end) + |> Enum.reduce(%{}, fn {key, value_str}, acc -> + group_id_str = String.slice(key, 6, String.length(key) - 6) + filter_value = parse_group_filter_value(value_str) + Map.put(acc, group_id_str, filter_value) + end) + # Parse boolean custom field filters (including nil values for "all") custom_boolean_filters_parsed = params @@ -288,6 +381,21 @@ defmodule MvWeb.Components.MemberFilterComponent do send(self(), {:payment_filter_changed, payment_filter}) end + # Update group filters - send event for each changed group + current_group_filters = socket.assigns.group_filters + all_group_ids = MapSet.new(Enum.map(socket.assigns.groups, &to_string(&1.id))) + + Enum.each(group_filters_parsed, fn {group_id_str, new_value} -> + in_set = MapSet.member?(all_group_ids, group_id_str) + current_value = Map.get(current_group_filters, group_id_str) + normalized_new = if new_value == nil, do: nil, else: new_value + should_send = in_set and current_value != normalized_new + + if should_send do + send(self(), {:group_filter_changed, group_id_str, normalized_new}) + end + end) + # Update boolean filters - send events for each changed filter current_filters = socket.assigns.boolean_filters @@ -310,7 +418,7 @@ defmodule MvWeb.Components.MemberFilterComponent do def handle_event("reset_filters", _params, socket) do # Send single message to reset all filters at once (performance optimization) # This avoids N×2 load_members() calls when resetting multiple filters - send(self(), {:reset_all_filters, nil, %{}}) + send(self(), {:reset_all_filters, nil, %{}, %{}}) # Close dropdown after reset {:noreply, assign(socket, :open, false)} @@ -322,17 +430,49 @@ defmodule MvWeb.Components.MemberFilterComponent do defp parse_tri_state("all"), do: nil defp parse_tri_state(_), do: nil + defp parse_group_filter_value("in"), do: :in + defp parse_group_filter_value("not_in"), do: :not_in + defp parse_group_filter_value(_), do: nil + # Get display label for button - defp button_label(cycle_status_filter, boolean_custom_fields, boolean_filters) do - # If payment filter is active, show payment filter label - if cycle_status_filter do - payment_filter_label(cycle_status_filter) - else - # Otherwise show boolean filter labels - boolean_filter_label(boolean_custom_fields, boolean_filters) + defp button_label( + cycle_status_filter, + groups, + group_filters, + boolean_custom_fields, + boolean_filters + ) do + cond do + cycle_status_filter -> + payment_filter_label(cycle_status_filter) + + map_size(group_filters) > 0 -> + group_filters_label(groups, group_filters) + + map_size(boolean_filters) > 0 -> + boolean_filter_label(boolean_custom_fields, boolean_filters) + + true -> + gettext("All") end end + defp group_filters_label(_groups, group_filters) when map_size(group_filters) == 0, + do: gettext("All") + + defp group_filters_label(groups, group_filters) do + names = + group_filters + |> Enum.map(fn {group_id_str, _} -> + Enum.find(groups, fn g -> to_string(g.id) == group_id_str end) + end) + |> Enum.filter(&(&1 != nil)) + |> Enum.map(& &1.name) + + label = Enum.join(names, ", ") + truncate_label(label, 30) + end + # Get payment filter label defp payment_filter_label(nil), do: gettext("All") defp payment_filter_label(:paid), do: gettext("Paid") @@ -406,6 +546,39 @@ defmodule MvWeb.Components.MemberFilterComponent do end end + # Get CSS classes for per-group filter label based on current state + defp group_filter_label_class(group_filters, group_id, expected_value) do + base_classes = "join-item btn btn-sm" + current_value = Map.get(group_filters, to_string(group_id)) + is_active = current_value == expected_value + + cond do + expected_value == nil -> + if is_active do + "#{base_classes} btn-active" + else + "#{base_classes} btn" + end + + expected_value == :in -> + if is_active do + "#{base_classes} btn-success btn-active" + else + "#{base_classes} btn" + end + + expected_value == :not_in -> + if is_active do + "#{base_classes} btn-error btn-active" + else + "#{base_classes} btn" + end + + true -> + "#{base_classes} btn-outline" + end + end + # Get CSS classes for boolean filter label based on current state defp boolean_filter_label_class(boolean_filters, custom_field_id, expected_value) do base_classes = "join-item btn btn-sm" diff --git a/lib/mv_web/live/member_live/index.ex b/lib/mv_web/live/member_live/index.ex index 41f08c9..aa5f25a 100644 --- a/lib/mv_web/live/member_live/index.ex +++ b/lib/mv_web/live/member_live/index.ex @@ -41,6 +41,7 @@ defmodule MvWeb.MemberLive.Index do @custom_field_prefix Mv.Constants.custom_field_prefix() @boolean_filter_prefix Mv.Constants.boolean_filter_prefix() + @group_filter_prefix "group_" # Maximum number of boolean custom field filters allowed per request (DoS protection) @max_boolean_filters Mv.Constants.max_boolean_filters() @@ -121,7 +122,7 @@ defmodule MvWeb.MemberLive.Index do |> assign_new(:sort_field, fn -> :first_name end) |> assign_new(:sort_order, fn -> :asc end) |> assign(:cycle_status_filter, nil) - |> assign(:group_filter, nil) + |> assign(:group_filters, %{}) |> assign(:groups, groups) |> assign(:boolean_custom_field_filters, %{}) |> assign(:selected_members, MapSet.new()) @@ -250,7 +251,7 @@ defmodule MvWeb.MemberLive.Index do socket.assigns.sort_field, socket.assigns.sort_order, socket.assigns.cycle_status_filter, - socket.assigns[:group_filter], + socket.assigns[:group_filters], new_show_current, socket.assigns.boolean_custom_field_filters ) @@ -264,35 +265,6 @@ defmodule MvWeb.MemberLive.Index do {:noreply, push_patch(socket, to: new_path, replace: true)} end - @impl true - def handle_event("group_filter_changed", %{"group_filter" => group_id_param}, socket) do - group_filter = normalize_group_filter(group_id_param, socket.assigns.groups) - - socket = - socket - |> assign(:group_filter, group_filter) - |> load_members() - |> update_selection_assigns() - - query_params = - build_query_params( - socket.assigns.query, - socket.assigns.sort_field, - socket.assigns.sort_order, - socket.assigns.cycle_status_filter, - group_filter, - socket.assigns.show_current_cycle, - socket.assigns.boolean_custom_field_filters - ) - |> maybe_add_field_selection( - socket.assigns[:user_field_selection], - socket.assigns[:fields_in_url?] || false - ) - - new_path = ~p"/members?#{query_params}" - {:noreply, push_patch(socket, to: new_path, replace: true)} - end - @impl true def handle_event("copy_emails", _params, socket) do selected_ids = socket.assigns.selected_members @@ -390,7 +362,7 @@ defmodule MvWeb.MemberLive.Index do export_sort_field(socket.assigns.sort_field), export_sort_order(socket.assigns.sort_order), socket.assigns.cycle_status_filter, - socket.assigns[:group_filter], + socket.assigns[:group_filters], socket.assigns.show_current_cycle, socket.assigns.boolean_custom_field_filters ) @@ -416,7 +388,7 @@ defmodule MvWeb.MemberLive.Index do socket.assigns.sort_field, socket.assigns.sort_order, socket.assigns.cycle_status_filter, - socket.assigns[:group_filter], + socket.assigns[:group_filters], socket.assigns.show_current_cycle, socket.assigns.boolean_custom_field_filters ) @@ -444,7 +416,7 @@ defmodule MvWeb.MemberLive.Index do socket.assigns.sort_field, socket.assigns.sort_order, filter, - socket.assigns[:group_filter], + socket.assigns[:group_filters], socket.assigns.show_current_cycle, socket.assigns.boolean_custom_field_filters ) @@ -478,7 +450,7 @@ defmodule MvWeb.MemberLive.Index do socket.assigns.sort_field, socket.assigns.sort_order, socket.assigns.cycle_status_filter, - socket.assigns[:group_filter], + socket.assigns[:group_filters], socket.assigns.show_current_cycle, updated_filters ) @@ -491,12 +463,55 @@ defmodule MvWeb.MemberLive.Index do {:noreply, push_patch(socket, to: new_path, replace: true)} end + @impl true + def handle_info({:group_filter_changed, group_id_str, filter_value}, socket) do + normalized_id = normalize_uuid_string(group_id_str) || group_id_str + + group_filters = + if filter_value == nil do + Map.delete(socket.assigns.group_filters, normalized_id) + else + Map.put(socket.assigns.group_filters, normalized_id, filter_value) + end + + socket = + socket + |> assign(:group_filters, group_filters) + |> load_members() + |> update_selection_assigns() + + query_params = + build_query_params( + socket.assigns.query, + socket.assigns.sort_field, + socket.assigns.sort_order, + socket.assigns.cycle_status_filter, + group_filters, + socket.assigns.show_current_cycle, + socket.assigns.boolean_custom_field_filters + ) + |> maybe_add_field_selection( + socket.assigns[:user_field_selection], + socket.assigns[:fields_in_url?] || false + ) + + new_path = ~p"/members?#{query_params}" + {:noreply, push_patch(socket, to: new_path, replace: true)} + end + @impl true def handle_info({:reset_all_filters, cycle_status_filter, boolean_filters}, socket) do + handle_info({:reset_all_filters, cycle_status_filter, boolean_filters, %{}}, socket) + end + + def handle_info( + {:reset_all_filters, cycle_status_filter, boolean_filters, group_filters}, + socket + ) do socket = socket |> assign(:cycle_status_filter, cycle_status_filter) - |> assign(:group_filter, nil) + |> assign(:group_filters, group_filters) |> assign(:boolean_custom_field_filters, boolean_filters) |> load_members() |> update_selection_assigns() @@ -507,7 +522,7 @@ defmodule MvWeb.MemberLive.Index do socket.assigns.sort_field, socket.assigns.sort_order, cycle_status_filter, - socket.assigns[:group_filter], + socket.assigns[:group_filters], socket.assigns.show_current_cycle, boolean_filters ) @@ -644,7 +659,7 @@ defmodule MvWeb.MemberLive.Index do |> maybe_update_search(params) |> maybe_update_sort(params) |> maybe_update_cycle_status_filter(params) - |> maybe_update_group_filter(params) + |> maybe_update_group_filters(params) |> maybe_update_boolean_filters(params) |> maybe_update_show_current_cycle(params) |> assign(:fields_in_url?, fields_in_url?) @@ -678,7 +693,7 @@ defmodule MvWeb.MemberLive.Index do socket.assigns.sort_field, socket.assigns.sort_order, socket.assigns.cycle_status_filter, - socket.assigns[:group_filter], + socket.assigns[:group_filters], socket.assigns.show_current_cycle, socket.assigns.boolean_custom_field_filters, socket.assigns.user_field_selection, @@ -772,7 +787,7 @@ defmodule MvWeb.MemberLive.Index do socket.assigns.sort_field, socket.assigns.sort_order, socket.assigns.cycle_status_filter, - socket.assigns[:group_filter], + socket.assigns[:group_filters], socket.assigns.show_current_cycle, socket.assigns.boolean_custom_field_filters ) @@ -791,7 +806,7 @@ defmodule MvWeb.MemberLive.Index do sort_field, sort_order, cycle_status_filter, - group_filter, + group_filters, show_current_cycle, boolean_filters ) do @@ -823,11 +838,10 @@ defmodule MvWeb.MemberLive.Index do end base_params = - if group_filter && group_filter != "" do - Map.put(base_params, "group_filter", to_string(group_filter)) - else - base_params - end + Enum.reduce(group_filters, base_params, fn {group_id_str, value}, acc -> + param_value = if value == :in, do: "in", else: "not_in" + Map.put(acc, "#{@group_filter_prefix}#{group_id_str}", param_value) + end) base_params = if show_current_cycle do @@ -884,7 +898,7 @@ defmodule MvWeb.MemberLive.Index do query = apply_search_filter(query, search_query) - query = apply_group_filter(query, socket.assigns[:group_filter]) + query = apply_group_filters(query, socket.assigns[:group_filters], socket.assigns[:groups]) # Use ALL custom fields for sorting (not just show_in_overview subset) custom_fields_for_sort = socket.assigns.all_custom_fields @@ -963,13 +977,50 @@ defmodule MvWeb.MemberLive.Index do end end - defp apply_group_filter(query, nil), do: query - defp apply_group_filter(query, ""), do: query + defp apply_group_filters(query, group_filters, _groups) when group_filters == %{}, do: query - defp apply_group_filter(query, group_id) when is_binary(group_id) do - Ash.Query.filter(query, expr(exists(groups, id == ^group_id))) + defp apply_group_filters(query, group_filters, groups) do + valid_ids = + groups + |> Enum.map(&normalize_uuid_string(to_string(&1.id))) + |> Enum.reject(&is_nil/1) + |> MapSet.new() + + Enum.reduce(group_filters, query, fn {group_id_str, value}, q -> + member? = MapSet.member?(valid_ids, group_id_str) + + if member? do + apply_one_group_filter(q, group_id_str, value) + else + q + end + end) end + defp apply_one_group_filter(query, _group_id_str, nil), do: query + + defp apply_one_group_filter(query, group_id_str, :in) do + case Ecto.UUID.cast(group_id_str) do + {:ok, group_uuid} -> + Ash.Query.filter(query, expr(exists(member_groups, group_id == ^group_uuid))) + + _ -> + query + end + end + + defp apply_one_group_filter(query, group_id_str, :not_in) do + case Ecto.UUID.cast(group_id_str) do + {:ok, group_uuid} -> + Ash.Query.filter(query, expr(not exists(member_groups, group_id == ^group_uuid))) + + _ -> + query + end + end + + defp apply_one_group_filter(query, _, _), do: query + defp apply_cycle_status_filter(members, nil, _show_current), do: members defp apply_cycle_status_filter(members, status, show_current) @@ -1097,17 +1148,15 @@ defmodule MvWeb.MemberLive.Index do end defp sort_members_in_memory(members, field, order, custom_fields) do - cond do - field in [:groups, "groups"] -> - sort_members_by_groups(members, order) + if field in [:groups, "groups"] do + sort_members_by_groups(members, order) + else + custom_field_id_str = extract_custom_field_id(field) - true -> - custom_field_id_str = extract_custom_field_id(field) - - case custom_field_id_str do - nil -> members - id_str -> sort_members_by_custom_field(members, id_str, order, custom_fields) - end + case custom_field_id_str do + nil -> members + id_str -> sort_members_by_custom_field(members, id_str, order, custom_fields) + end end end @@ -1223,8 +1272,12 @@ defmodule MvWeb.MemberLive.Index do defp determine_field_after_computed_check(default, sf) when is_binary(sf) do cond do - sf == "groups" -> :groups - custom_field_sort?(sf) -> if valid_sort_field?(sf), do: sf, else: default + sf == "groups" -> + :groups + + custom_field_sort?(sf) -> + if valid_sort_field?(sf), do: sf, else: default + true -> atom = safe_member_field_atom_only(sf) if atom != nil and valid_sort_field?(atom), do: atom, else: default @@ -1257,31 +1310,61 @@ defmodule MvWeb.MemberLive.Index do defp maybe_update_cycle_status_filter(socket, _params), do: assign(socket, :cycle_status_filter, nil) - defp maybe_update_group_filter(socket, %{"group_filter" => group_id_param}) do - group_filter = normalize_group_filter(group_id_param, socket.assigns.groups) - assign(socket, :group_filter, group_filter) + defp maybe_update_group_filters(socket, params) when is_map(params) do + prefix = @group_filter_prefix + prefix_len = String.length(prefix) + + group_param_entries = + params + |> Enum.filter(fn {key, _} -> + key_str = to_string(key) + String.starts_with?(key_str, prefix) + end) + + filters = + Enum.reduce(group_param_entries, %{}, fn {key, value_str}, acc -> + add_group_filter_entry(acc, key, value_str, prefix_len) + end) + + assign(socket, :group_filters, filters) end - defp maybe_update_group_filter(socket, _params), do: socket + defp maybe_update_group_filters(socket, _), do: socket - defp normalize_group_filter("", _groups), do: nil - defp normalize_group_filter(nil, _groups), do: nil + defp add_group_filter_entry(acc, key, value_str, prefix_len) do + key_str = to_string(key) + raw_id = String.slice(key_str, prefix_len, String.length(key_str) - prefix_len) + group_id_str = normalize_uuid_string(raw_id) + valid_id? = group_id_str && String.length(group_id_str) <= @max_uuid_length - defp normalize_group_filter(group_id_param, groups) when is_binary(group_id_param) do - case Ecto.UUID.cast(group_id_param) do - {:ok, _uuid} -> - if Enum.any?(groups, fn g -> to_string(g.id) == group_id_param end) do - group_id_param - else - nil - end - - :error -> - nil + if valid_id? do + case parse_group_filter_value(value_str) do + nil -> acc + value -> Map.put(acc, group_id_str, value) + end + else + acc end end - defp normalize_group_filter(_, _), do: nil + # Normalize UUID string so URL params match valid_ids (lowercase, canonical format) + defp normalize_uuid_string(raw) when is_binary(raw) do + case Ecto.UUID.cast(String.trim(raw)) do + {:ok, uuid} -> to_string(uuid) + _ -> raw + end + end + + defp normalize_uuid_string(_), do: nil + + defp parse_group_filter_value("in"), do: :in + defp parse_group_filter_value("not_in"), do: :not_in + + defp parse_group_filter_value(val) when is_binary(val) do + parse_group_filter_value(String.trim(val)) + end + + defp parse_group_filter_value(_), do: nil defp determine_cycle_status_filter("paid"), do: :paid defp determine_cycle_status_filter("unpaid"), do: :unpaid diff --git a/lib/mv_web/live/member_live/index.html.heex b/lib/mv_web/live/member_live/index.html.heex index b72e598..f809490 100644 --- a/lib/mv_web/live/member_live/index.html.heex +++ b/lib/mv_web/live/member_live/index.html.heex @@ -52,26 +52,12 @@ query={@query} placeholder={gettext("Search...")} /> - - - <.live_component module={MvWeb.Components.MemberFilterComponent} id="member-filter" cycle_status_filter={@cycle_status_filter} + groups={@groups} + group_filters={@group_filters} boolean_custom_fields={@boolean_custom_fields} boolean_filters={@boolean_custom_field_filters} member_count={length(@members)} diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po index 2124806..968885c 100644 --- a/priv/gettext/de/LC_MESSAGES/default.po +++ b/priv/gettext/de/LC_MESSAGES/default.po @@ -2191,7 +2191,9 @@ msgid "Group saved successfully." msgstr "Gruppe erfolgreich gespeichert." #: 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/member_live/index.html.heex #, elixir-autogen, elixir-format msgid "Groups" msgstr "Gruppen" @@ -2468,22 +2470,7 @@ msgstr "Pausiert" msgid "unpaid" msgstr "Unbezahlt" -#~ #: lib/mv_web/live/global_settings_live.ex -#~ #, elixir-autogen, elixir-format, fuzzy -#~ msgid "Custom Fields in CSV Import" -#~ msgstr "Benutzerdefinierte Felder" - -#~ #: 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" +#: lib/mv_web/live/member_live/index.html.heex +#, elixir-autogen, elixir-format +msgid "Member of group %{name}" +msgstr "Mitglied der Gruppe %{name}" diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot index fd13c73..49aaa94 100644 --- a/priv/gettext/default.pot +++ b/priv/gettext/default.pot @@ -2192,7 +2192,9 @@ msgid "Group saved successfully." msgstr "" #: 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/member_live/index.html.heex #, elixir-autogen, elixir-format msgid "Groups" msgstr "" @@ -2468,3 +2470,8 @@ msgstr "" #, elixir-autogen, elixir-format msgid "unpaid" msgstr "" + +#: lib/mv_web/live/member_live/index.html.heex +#, elixir-autogen, elixir-format +msgid "Member of group %{name}" +msgstr "" diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po index 0b13b6b..0b0efea 100644 --- a/priv/gettext/en/LC_MESSAGES/default.po +++ b/priv/gettext/en/LC_MESSAGES/default.po @@ -13,6 +13,7 @@ msgstr "" #: lib/mv_web/components/core_components.ex #: lib/mv_web/live/group_live/index.ex +#: lib/mv_web/live/group_live/show.ex #, elixir-autogen, elixir-format msgid "Actions" 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." msgstr "" +#: lib/mv_web/live/group_live/show.ex #: lib/mv_web/live/user_live/form.ex #, elixir-autogen, elixir-format msgid "Available members" @@ -2190,7 +2192,9 @@ msgid "Group saved successfully." msgstr "" #: 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/member_live/index.html.heex #, elixir-autogen, elixir-format msgid "Groups" msgstr "" @@ -2254,6 +2258,66 @@ msgstr "" msgid "Could not load member search. Please try again." 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 #, elixir-autogen, elixir-format, fuzzy msgid "CSV files only, maximum %{size} MB" @@ -2407,22 +2471,7 @@ msgstr "" msgid "unpaid" msgstr "" -#~ #: lib/mv_web/live/global_settings_live.ex -#~ #, elixir-autogen, elixir-format, fuzzy -#~ msgid "Custom Fields in CSV Import" -#~ 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 "" +#: lib/mv_web/live/member_live/index.html.heex +#, elixir-autogen, elixir-format +msgid "Member of group %{name}" +msgstr "" diff --git a/test/mv_web/member_live/index_groups_accessibility_test.exs b/test/mv_web/member_live/index_groups_accessibility_test.exs index b59209f..7faad6e 100644 --- a/test/mv_web/member_live/index_groups_accessibility_test.exs +++ b/test/mv_web/member_live/index_groups_accessibility_test.exs @@ -62,18 +62,21 @@ defmodule MvWeb.MemberLive.IndexGroupsAccessibilityTest do end @tag :ui - test "filter dropdown has aria-label", %{ + test "filter dropdown has group presence section with legend", %{ conn: conn } do conn = conn_with_oidc_user(conn) - {:ok, view, html} = live(conn, "/members") + {:ok, view, _html} = live(conn, "/members") - # Verify filter dropdown has aria-label - assert html =~ ~r/select.*name=["']group_filter["'].*aria-label=/ or - html =~ ~r/aria-label=.*[Gg]roup/ + # Open filter dropdown + view + |> element("button[aria-label='Filter members']") + |> render_click() - # Verify dropdown is present - assert has_element?(view, "select[name='group_filter']") + html = render(view) + # 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 @tag :ui @@ -92,26 +95,22 @@ defmodule MvWeb.MemberLive.IndexGroupsAccessibilityTest do @tag :ui test "keyboard navigation works for filter dropdown", %{ conn: conn, + member1: member1, group1: group1 } do conn = conn_with_oidc_user(conn) {: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 - |> element("#group-filter-form") - |> render_change(%{"group_filter" => group1.id}) + |> element("button[aria-label='Filter members']") + |> 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) - assert html + assert html =~ member1.first_name end @tag :ui @@ -121,18 +120,14 @@ defmodule MvWeb.MemberLive.IndexGroupsAccessibilityTest do conn = conn_with_oidc_user(conn) {: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']") - # Test that sort header can be activated via click (simulating keyboard) view |> element("[data-testid='groups']") |> render_click() - # Verify sort was applied - assert_patch(view, "/members?query=&sort_field=groups&sort_order=asc") + # Verify sort was applied (URL may include other params) + assert has_element?(view, "[data-testid='groups'][aria-label*='ascending']") end @tag :ui @@ -144,19 +139,16 @@ defmodule MvWeb.MemberLive.IndexGroupsAccessibilityTest do conn = conn_with_oidc_user(conn) {:ok, view, _html} = live(conn, "/members") - # Apply filter view - |> element("#group-filter-form") - |> render_change(%{"group_filter" => group1.id}) + |> element("button[aria-label='Filter members']") + |> 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) - # Should show filtered results assert html =~ member1.first_name - - # Verify member count or filter status is announced - # (Implementation might use aria-live="polite" for announcements) - assert html end @tag :ui diff --git a/test/mv_web/member_live/index_groups_display_test.exs b/test/mv_web/member_live/index_groups_display_test.exs index 2154347..b28b978 100644 --- a/test/mv_web/member_live/index_groups_display_test.exs +++ b/test/mv_web/member_live/index_groups_display_test.exs @@ -64,7 +64,11 @@ defmodule MvWeb.MemberLive.IndexGroupsDisplayTest do %{member1: member1, member2: member2, member3: member3, group1: group1, group2: group2} 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) {:ok, _view, html} = live(conn, "/members") diff --git a/test/mv_web/member_live/index_groups_filter_test.exs b/test/mv_web/member_live/index_groups_filter_test.exs index 7f0b3f0..c92a0bd 100644 --- a/test/mv_web/member_live/index_groups_filter_test.exs +++ b/test/mv_web/member_live/index_groups_filter_test.exs @@ -1,6 +1,9 @@ defmodule MvWeb.MemberLive.IndexGroupsFilterTest do @moduledoc """ 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 use MvWeb.ConnCase, async: false @@ -53,7 +56,28 @@ defmodule MvWeb.MemberLive.IndexGroupsFilterTest do %{member1: member1, member2: member2, member3: member3, group1: group1, group2: group2} 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) {:ok, _view, html} = live(conn, "/members") assert html =~ m1.first_name @@ -61,7 +85,7 @@ defmodule MvWeb.MemberLive.IndexGroupsFilterTest do assert html =~ m3.first_name end - test "filter by specific group shows only members in that group", %{ + test "filter group1 Yes shows only members in group1", %{ conn: conn, member1: m1, member2: m2, @@ -71,9 +95,7 @@ defmodule MvWeb.MemberLive.IndexGroupsFilterTest do conn = conn_with_oidc_user(conn) {:ok, view, _html} = live(conn, "/members") - view - |> element("#group-filter-form") - |> render_change(%{"group_filter" => group1.id}) + open_filter_and_set_group(view, group1.id, "in") html = render(view) assert html =~ m1.first_name @@ -81,21 +103,45 @@ defmodule MvWeb.MemberLive.IndexGroupsFilterTest do refute html =~ m3.first_name 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) {:ok, view, _html} = live(conn, "/members") - view - |> element("#group-filter-form") - |> render_change(%{"group_filter" => group1.id}) + open_filter_and_set_group(view, group1.id, "not_in") + + 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) 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_filter=#{group1.id}") + {:ok, _view2, html2} = live(conn, "/members?group_#{group1.id}=in") assert html2 =~ m1.first_name + refute html2 =~ m2.first_name + refute html2 =~ m3.first_name end test "filter is restored from URL on load", %{ @@ -106,7 +152,7 @@ defmodule MvWeb.MemberLive.IndexGroupsFilterTest do group1: group1 } do 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 refute html =~ m2.first_name refute html =~ m3.first_name diff --git a/test/mv_web/member_live/index_groups_integration_test.exs b/test/mv_web/member_live/index_groups_integration_test.exs index 9d04af8..7a45dc0 100644 --- a/test/mv_web/member_live/index_groups_integration_test.exs +++ b/test/mv_web/member_live/index_groups_integration_test.exs @@ -102,8 +102,12 @@ defmodule MvWeb.MemberLive.IndexGroupsIntegrationTest do {:ok, view, _html} = live(conn, "/members") view - |> element("#group-filter-form") - |> render_change(%{"group_filter" => group1.id}) + |> element("button[aria-label='Filter members']") + |> render_click() + + view + |> element("[data-testid='member-filter-form']") + |> render_change(%{"group_#{group1.id}" => "in", "payment_filter" => "all"}) html = render(view) assert html =~ member1.first_name @@ -160,13 +164,13 @@ defmodule MvWeb.MemberLive.IndexGroupsIntegrationTest do |> Ash.create(actor: system_actor) 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} = - 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 =~ group1.name + # member1 has a group and a paid cycle; page should load with both filters + assert html =~ member1.first_name end test "groups work with existing search (not testing search integration)", %{ @@ -180,8 +184,12 @@ defmodule MvWeb.MemberLive.IndexGroupsIntegrationTest do # Apply group filter view - |> element("#group-filter-form") - |> render_change(%{"group_filter" => group1.id}) + |> element("button[aria-label='Filter members']") + |> 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; # search form is in SearchBarComponent with phx-submit="search") @@ -208,8 +216,12 @@ defmodule MvWeb.MemberLive.IndexGroupsIntegrationTest do # Apply group filter view - |> element("#group-filter-form") - |> render_change(%{"group_filter" => group1.id}) + |> element("button[aria-label='Filter members']") + |> render_click() + + view + |> element("[data-testid='member-filter-form']") + |> render_change(%{"group_#{group1.id}" => "in", "payment_filter" => "all"}) # Apply sorting view diff --git a/test/mv_web/member_live/index_groups_performance_test.exs b/test/mv_web/member_live/index_groups_performance_test.exs index c1d2835..daa2e7f 100644 --- a/test/mv_web/member_live/index_groups_performance_test.exs +++ b/test/mv_web/member_live/index_groups_performance_test.exs @@ -97,30 +97,28 @@ defmodule MvWeb.MemberLive.IndexGroupsPerformanceTest do conn = conn_with_oidc_user(conn) {:ok, view, _html} = live(conn, "/members") - # Apply filter + # Open filter and apply "Yes" for group1 (even-indexed members are in group1) view - |> element("#group-filter-form") - |> render_change(%{"group_filter" => group1.id}) + |> element("button[aria-label='Filter members']") + |> 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) - # Members with even indices (0, 2, 4, 6, 8) are in group1 - even_members = Enum.filter(0..9, &(rem(&1, 2) == 0)) - odd_members = Enum.filter(0..9, &(rem(&1, 2) == 1)) - - Enum.each(even_members, fn i -> + # Only even-indexed members (0,2,4,6,8) are in group1 + Enum.each([0, 2, 4, 6, 8], fn i -> member = Enum.at(members, i) assert html =~ member.first_name end) - Enum.each(odd_members, fn i -> + Enum.each([1, 3, 5, 7, 9], fn i -> member = Enum.at(members, i) refute html =~ member.first_name end) - - # If filtering was done in-memory, we'd load all members first - # Database-level filtering is more efficient end @tag :slow diff --git a/test/mv_web/member_live/index_groups_url_params_test.exs b/test/mv_web/member_live/index_groups_url_params_test.exs index 4c73e96..469b010 100644 --- a/test/mv_web/member_live/index_groups_url_params_test.exs +++ b/test/mv_web/member_live/index_groups_url_params_test.exs @@ -3,7 +3,7 @@ defmodule MvWeb.MemberLive.IndexGroupsUrlParamsTest do Tests for URL parameter persistence for groups in the member overview. Tests cover: - - Group filter is written to URL (group_filter=) + - 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) - URL parameters are restored on load - 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", %{ conn: conn, + member1: member1, group1: group1 } do conn = conn_with_oidc_user(conn) {:ok, view, _html} = live(conn, "/members") - # Apply group filter view - |> element("#group-filter-form") - |> render_change(%{"group_filter" => group1.id}) + |> element("button[aria-label='Filter members']") + |> 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) - assert html =~ group1.name + assert html =~ member1.first_name end test "group sorting is written to URL", %{ @@ -92,13 +95,10 @@ defmodule MvWeb.MemberLive.IndexGroupsUrlParamsTest do conn = conn_with_oidc_user(conn) {: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 refute html =~ member2.first_name - - # Verify sort is applied assert has_element?(view, "[data-testid='groups'][aria-label*='ascending']") end @@ -108,23 +108,22 @@ defmodule MvWeb.MemberLive.IndexGroupsUrlParamsTest do group1: group1 } do 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 end test "URL parameters work with other sort fields", %{ conn: conn, + member1: member1, group1: group1 } do conn = conn_with_oidc_user(conn) {: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 =~ group1.name + assert html =~ member1.first_name assert has_element?(view, "[data-testid='first_name'][aria-label*='descending']") end @@ -134,37 +133,28 @@ defmodule MvWeb.MemberLive.IndexGroupsUrlParamsTest do group1: group1 } do conn = conn_with_oidc_user(conn) - # Simulate bookmarking a URL with filter and sort - bookmark_url = "/members?group_filter=#{group1.id}&sort_field=groups&sort_order=asc" + bookmark_url = "/members?group_#{group1.id}=in&sort_field=groups&sort_order=asc" {:ok, view, html} = live(conn, bookmark_url) - # Verify filter and sort are both applied when loading bookmarked URL assert html =~ member1.first_name assert has_element?(view, "[data-testid='groups'][aria-label*='ascending']") end - test "handles multiple group_filter parameters (uses last one)", %{ + test "handles multiple group filter parameters (uses last one)", %{ conn: conn, + member1: member1, + member2: member2, group1: group1 } 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) - # URL with duplicate parameters (should use last one) - {:ok, view, _html} = - live(conn, "/members?group_filter=#{group1.id}&group_filter=#{group2.id}") + # Duplicate param for same group: last wins. group_id=in then not_in -> not_in + {:ok, _view, html} = + live(conn, "/members?group_#{group1.id}=in&group_#{group1.id}=not_in") - # Verify the last filter value is used - # Implementation should handle this gracefully - html = render(view) - # Should show members from group2 (last filter) - assert html + # not_in group1: member2 and member3 (member1 is in group1) + refute html =~ member1.first_name + assert html =~ member2.first_name end test "handles invalid URL parameters gracefully", %{ @@ -173,11 +163,10 @@ defmodule MvWeb.MemberLive.IndexGroupsUrlParamsTest do member2: member2 } do conn = conn_with_oidc_user(conn) - # URL with invalid group_filter (non-existent UUID) 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 =~ member2.first_name end @@ -188,10 +177,8 @@ defmodule MvWeb.MemberLive.IndexGroupsUrlParamsTest do member2: member2 } do conn = conn_with_oidc_user(conn) - # URL with malformed group_filter (not a UUID) - {:ok, view, html} = live(conn, "/members?group_filter=not-a-uuid") + {:ok, _view, html} = live(conn, "/members?group_not-a-uuid=in") - # Verify all members are shown (malformed filter ignored) assert html =~ member1.first_name assert html =~ member2.first_name end