diff --git a/docs/feature-roadmap.md b/docs/feature-roadmap.md index 3812598..67f01c8 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..ef6f32e 100644 --- a/lib/mv_web/live/components/member_filter_component.ex +++ b/lib/mv_web/live/components/member_filter_component.ex @@ -16,6 +16,9 @@ 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). + Multiple active filters combine with AND (member must match all selected group conditions). - `: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,10 +26,13 @@ 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 + @group_filter_prefix "group_" + @impl true def mount(socket) do {:ok, assign(socket, :open, false)} @@ -38,6 +44,9 @@ 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(:group_filter_prefix, @group_filter_prefix) |> assign(:boolean_custom_fields, assigns[:boolean_custom_fields] || []) |> assign(:boolean_filters, assigns[:boolean_filters] || %{}) |> assign(:member_count, assigns[:member_count] || 0) @@ -60,7 +69,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 +81,13 @@ defmodule MvWeb.Components.MemberFilterComponent do > <.icon name="hero-funnel" class="h-5 w-5" /> 0} @@ -79,7 +96,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 +123,7 @@ defmodule MvWeb.Components.MemberFilterComponent do role="dialog" aria-label={gettext("Member filter")} > -
+
@@ -162,6 +182,73 @@ defmodule MvWeb.Components.MemberFilterComponent do
+ +
0} class="mb-4"> +
+ {gettext("Groups")} +
+
+
+ + {group.name} + +
+ + + +
+
+
+
+
0} class="mb-2">
@@ -274,6 +361,18 @@ defmodule MvWeb.Components.MemberFilterComponent do _ -> nil end + # Parse per-group filters (params keys "group_" => "all"|"in"|"not_in") + prefix_len = String.length(@group_filter_prefix) + + group_filters_parsed = + params + |> Enum.filter(fn {key, _} -> String.starts_with?(key, @group_filter_prefix) end) + |> Enum.reduce(%{}, fn {key, value_str}, acc -> + group_id_str = String.slice(key, prefix_len, String.length(key) - prefix_len) + 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 +387,20 @@ 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) + should_send = in_set and current_value != new_value + + if should_send do + send(self(), {:group_filter_changed, group_id_str, new_value}) + end + end) + # Update boolean filters - send events for each changed filter current_filters = socket.assigns.boolean_filters @@ -310,7 +423,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 +435,48 @@ 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 + groups_by_id = Map.new(groups, fn g -> {to_string(g.id), g.name} end) + + names = + group_filters + |> Enum.map(fn {group_id_str, _} -> Map.get(groups_by_id, group_id_str) end) + |> Enum.reject(&is_nil/1) + + 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 +550,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 d6dd082..59ee8f9 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() @@ -85,6 +86,12 @@ defmodule MvWeb.MemberLive.Index do |> Enum.filter(&(&1.value_type == :boolean)) |> Enum.sort_by(& &1.name, :asc) + # Load groups for filter dropdown (sorted by name) + groups = + Mv.Membership.Group + |> Ash.Query.sort(name: :asc) + |> Ash.read!(actor: actor) + # Load settings once to avoid N+1 queries settings = case Membership.get_settings() do @@ -115,6 +122,8 @@ 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_filters, %{}) + |> assign(:groups, groups) |> assign(:boolean_custom_field_filters, %{}) |> assign(:selected_members, MapSet.new()) |> assign(:settings, settings) @@ -242,6 +251,7 @@ defmodule MvWeb.MemberLive.Index do socket.assigns.sort_field, socket.assigns.sort_order, socket.assigns.cycle_status_filter, + socket.assigns[:group_filters], new_show_current, socket.assigns.boolean_custom_field_filters ) @@ -352,6 +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_filters], socket.assigns.show_current_cycle, socket.assigns.boolean_custom_field_filters ) @@ -377,6 +388,7 @@ defmodule MvWeb.MemberLive.Index do socket.assigns.sort_field, socket.assigns.sort_order, socket.assigns.cycle_status_filter, + socket.assigns[:group_filters], socket.assigns.show_current_cycle, socket.assigns.boolean_custom_field_filters ) @@ -404,6 +416,7 @@ defmodule MvWeb.MemberLive.Index do socket.assigns.sort_field, socket.assigns.sort_order, filter, + socket.assigns[:group_filters], socket.assigns.show_current_cycle, socket.assigns.boolean_custom_field_filters ) @@ -437,6 +450,7 @@ defmodule MvWeb.MemberLive.Index do socket.assigns.sort_field, socket.assigns.sort_order, socket.assigns.cycle_status_filter, + socket.assigns[:group_filters], socket.assigns.show_current_cycle, updated_filters ) @@ -449,11 +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_filters, group_filters) |> assign(:boolean_custom_field_filters, boolean_filters) |> load_members() |> update_selection_assigns() @@ -464,6 +522,7 @@ defmodule MvWeb.MemberLive.Index do socket.assigns.sort_field, socket.assigns.sort_order, cycle_status_filter, + socket.assigns[:group_filters], socket.assigns.show_current_cycle, boolean_filters ) @@ -600,6 +659,7 @@ defmodule MvWeb.MemberLive.Index do |> maybe_update_search(params) |> maybe_update_sort(params) |> maybe_update_cycle_status_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?) @@ -633,6 +693,7 @@ defmodule MvWeb.MemberLive.Index do socket.assigns.sort_field, socket.assigns.sort_order, socket.assigns.cycle_status_filter, + socket.assigns[:group_filters], socket.assigns.show_current_cycle, socket.assigns.boolean_custom_field_filters, socket.assigns.user_field_selection, @@ -726,6 +787,7 @@ defmodule MvWeb.MemberLive.Index do socket.assigns.sort_field, socket.assigns.sort_order, socket.assigns.cycle_status_filter, + socket.assigns[:group_filters], socket.assigns.show_current_cycle, socket.assigns.boolean_custom_field_filters ) @@ -744,11 +806,13 @@ defmodule MvWeb.MemberLive.Index do sort_field, sort_order, cycle_status_filter, + group_filters, show_current_cycle, boolean_filters ) do base_params = build_base_params(query, sort_field, sort_order) base_params = add_cycle_status_filter(base_params, cycle_status_filter) + base_params = add_group_filters(base_params, group_filters) base_params = add_show_current_cycle(base_params, show_current_cycle) add_boolean_filters(base_params, boolean_filters) end @@ -771,6 +835,13 @@ defmodule MvWeb.MemberLive.Index do defp normalize_sort_order(order) when is_binary(order), do: order defp normalize_sort_order(_), do: "" + defp add_group_filters(params, group_filters) do + Enum.reduce(group_filters, 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) + end + defp add_cycle_status_filter(params, nil), do: params defp add_cycle_status_filter(params, :paid), do: Map.put(params, "cycle_status_filter", "paid") @@ -827,8 +898,14 @@ defmodule MvWeb.MemberLive.Index do query = MembershipFeeStatus.load_cycles_for_members(query, socket.assigns.show_current_cycle) + # Load groups for each member (id, name, slug only) + query = + Ash.Query.load(query, groups: [:id, :name, :slug]) + query = apply_search_filter(query, search_query) + 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 @@ -864,7 +941,7 @@ defmodule MvWeb.MemberLive.Index do socket.assigns.all_custom_fields ) - # Sort in memory if needed (custom fields only; computed fields are blocked) + # Sort in memory if needed (custom fields, groups, group_count; computed fields are blocked) members = if sort_after_load and socket.assigns.sort_field not in FieldVisibility.computed_member_fields() do @@ -906,6 +983,51 @@ defmodule MvWeb.MemberLive.Index do end end + # Multiple group filters combine with AND: member must match all selected group conditions. + defp apply_group_filters(query, group_filters, _groups) when group_filters == %{}, do: query + + 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) @@ -941,6 +1063,10 @@ defmodule MvWeb.MemberLive.Index do defp apply_sort_to_query(query, field, order) do cond do + # Groups sort -> after load (in memory) + field in [:groups, "groups"] -> + {query, true} + # Custom field sort -> after load custom_field_sort?(field) -> {query, true} @@ -980,12 +1106,14 @@ defmodule MvWeb.MemberLive.Index do defp valid_sort_field_db_or_custom?(field) when is_atom(field) do non_sortable_fields = [:notes] valid_fields = Mv.Constants.member_fields() -- non_sortable_fields - field in valid_fields or custom_field_sort?(field) + field in valid_fields or custom_field_sort?(field) or field == :groups end defp valid_sort_field_db_or_custom?(field) when is_binary(field) do - custom_field_sort?(field) or - ((atom = safe_member_field_atom_only(field)) != nil and valid_sort_field_db_or_custom?(atom)) + normalized = if field == "groups", do: :groups, else: safe_member_field_atom_only(field) + + (normalized != nil and valid_sort_field_db_or_custom?(normalized)) or + custom_field_sort?(field) end defp safe_member_field_atom_only(str) do @@ -1028,14 +1156,35 @@ defmodule MvWeb.MemberLive.Index do end defp sort_members_in_memory(members, field, order, custom_fields) do - custom_field_id_str = extract_custom_field_id(field) + if field in [:groups, "groups"] do + sort_members_by_groups(members, order) + else + 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) + case custom_field_id_str do + nil -> members + id_str -> sort_members_by_custom_field(members, id_str, order, custom_fields) + end end end + defp sort_members_by_groups(members, order) do + # Members with groups first, then by first group name alphabetically (min = first by sort order) + first_group_name = fn member -> + (member.groups || []) + |> Enum.map(& &1.name) + |> Enum.min(fn -> nil end) + end + + members + |> Enum.sort_by(fn member -> + name = first_group_name.(member) + # Nil (no groups) sorts last in asc, first in desc + {name == nil, name || ""} + end) + |> then(fn list -> if order == :desc, do: Enum.reverse(list), else: list end) + end + defp sort_members_by_custom_field(members, id_str, order, custom_fields) do custom_field = find_custom_field_by_id(custom_fields, id_str) @@ -1130,11 +1279,16 @@ defmodule MvWeb.MemberLive.Index do defp determine_field(default, _), do: default defp determine_field_after_computed_check(default, sf) when is_binary(sf) do - if custom_field_sort?(sf) do - if valid_sort_field?(sf), do: sf, else: default - else - atom = safe_member_field_atom_only(sf) - if atom != nil and valid_sort_field?(atom), do: atom, else: default + cond do + 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 end end @@ -1164,6 +1318,62 @@ defmodule MvWeb.MemberLive.Index do defp maybe_update_cycle_status_filter(socket, _params), do: assign(socket, :cycle_status_filter, nil) + 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_filters(socket, _), do: socket + + 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 + + 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 + + # 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 defp determine_cycle_status_filter(_), do: nil diff --git a/lib/mv_web/live/member_live/index.html.heex b/lib/mv_web/live/member_live/index.html.heex index 3e9c520..311447b 100644 --- a/lib/mv_web/live/member_live/index.html.heex +++ b/lib/mv_web/live/member_live/index.html.heex @@ -48,6 +48,8 @@ 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)} @@ -302,6 +304,34 @@ {gettext("No cycle")} <% end %> + <:col + :let={member} + label={ + ~H""" + <.live_component + module={MvWeb.Components.SortHeaderComponent} + id={:sort_groups} + field={:groups} + label={gettext("Groups")} + sort_field={@sort_field} + sort_order={@sort_order} + /> + """ + } + > + <%= for group <- (member.groups || []) do %> + + {group.name} + + <% end %> + <%= if (member.groups || []) == [] do %> + — + <% end %> + <:action :let={member}>
<.link navigate={~p"/members/#{member}"}>{gettext("Show")} diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po index 26e1ff1..1784d4b 100644 --- a/priv/gettext/de/LC_MESSAGES/default.po +++ b/priv/gettext/de/LC_MESSAGES/default.po @@ -2199,7 +2199,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" @@ -2471,6 +2473,11 @@ msgstr "Pausiert" msgid "unpaid" msgstr "Unbezahlt" +#: lib/mv_web/live/member_live/index.html.heex +#, elixir-autogen, elixir-format +msgid "Member of group %{name}" +msgstr "Mitglied der Gruppe %{name}" + #: lib/mv_web/live/statistics_live.ex #, elixir-autogen, elixir-format msgid "Active members" diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot index 73152cd..af24afd 100644 --- a/priv/gettext/default.pot +++ b/priv/gettext/default.pot @@ -2200,7 +2200,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 "" @@ -2472,6 +2474,11 @@ msgstr "" msgid "unpaid" msgstr "" +#: lib/mv_web/live/member_live/index.html.heex +#, elixir-autogen, elixir-format +msgid "Member of group %{name}" +msgstr "" + #: lib/mv_web/live/statistics_live.ex #, elixir-autogen, elixir-format msgid "Active members" diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po index 3df18d4..88da6ff 100644 --- a/priv/gettext/en/LC_MESSAGES/default.po +++ b/priv/gettext/en/LC_MESSAGES/default.po @@ -2200,7 +2200,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 "" @@ -2472,6 +2474,11 @@ msgstr "" msgid "unpaid" msgstr "" +#: lib/mv_web/live/member_live/index.html.heex +#, elixir-autogen, elixir-format +msgid "Member of group %{name}" +msgstr "" + #: lib/mv_web/live/statistics_live.ex #, elixir-autogen, elixir-format, fuzzy msgid "Active members" diff --git a/test/mv_web/member_live/index_custom_fields_sorting_test.exs b/test/mv_web/member_live/index_custom_fields_sorting_test.exs index c8201fd..2f12fcc 100644 --- a/test/mv_web/member_live/index_custom_fields_sorting_test.exs +++ b/test/mv_web/member_live/index_custom_fields_sorting_test.exs @@ -145,8 +145,10 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsSortingTest do |> element("[data-testid='custom_field_#{field.id}']") |> render_click() - # Check URL was updated - assert_patch(view, "/members?query=&sort_field=custom_field_#{field.id}&sort_order=desc") + # Check URL was updated (param order may vary) + path = assert_patch(view) + assert path =~ "sort_order=desc" + assert path =~ "sort_field=custom_field_#{field.id}" # Verify sort state assert has_element?(view, "[data-testid='custom_field_#{field.id}'][aria-label='descending']") diff --git a/test/mv_web/member_live/index_groups_accessibility_test.exs b/test/mv_web/member_live/index_groups_accessibility_test.exs new file mode 100644 index 0000000..ab9b728 --- /dev/null +++ b/test/mv_web/member_live/index_groups_accessibility_test.exs @@ -0,0 +1,178 @@ +defmodule MvWeb.MemberLive.IndexGroupsAccessibilityTest do + @moduledoc """ + Tests for accessibility of groups feature in the member overview. + + Tests cover: + - Badges have role="status" and aria-label + - Filter dropdown has aria-label + - Sort header has aria-label for screen reader + - Keyboard navigation works (Tab through filter, sort header) + """ + # async: false to prevent PostgreSQL deadlocks when creating members and groups + use MvWeb.ConnCase, async: false + import Phoenix.LiveViewTest + require Ash.Query + + alias Mv.Membership.{Group, MemberGroup} + + setup do + system_actor = Mv.Helpers.SystemActor.get_system_actor() + + # Create test members + {:ok, member1} = + Mv.Membership.create_member( + %{first_name: "Alice", last_name: "Anderson", email: "alice@example.com"}, + actor: system_actor + ) + + # Create test groups + {:ok, group1} = + Group + |> Ash.Changeset.for_create(:create, %{name: "Board Members"}) + |> Ash.create(actor: system_actor) + + # Create member-group associations + {:ok, _mg1} = + MemberGroup + |> Ash.Changeset.for_create(:create, %{member_id: member1.id, group_id: group1.id}) + |> Ash.create(actor: system_actor) + + %{ + member1: member1, + group1: group1 + } + end + + @tag :ui + test "group badges have role and aria-label", %{ + conn: conn, + member1: member1, + group1: group1 + } do + conn = conn_with_oidc_user(conn) + {:ok, view, html} = live(conn, "/members") + + # Verify badges have role="status" and aria-label containing the group name + assert has_element?(view, "span[role='status'][aria-label*='#{group1.name}']") + assert html =~ group1.name + + # Verify member1's row contains the badge + assert html =~ member1.first_name + end + + @tag :ui + test "filter dropdown has group presence section with legend", %{ + conn: conn + } do + conn = conn_with_oidc_user(conn) + {:ok, view, _html} = live(conn, "/members") + + # Open filter dropdown + view + |> element("button[aria-label='Filter members']") + |> render_click() + + 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 + test "sort header has aria-label for screen reader", %{ + conn: conn + } do + conn = conn_with_oidc_user(conn) + {:ok, view, _html} = live(conn, "/members") + + # Verify sort header has aria-label describing the sort state + assert has_element?(view, "[data-testid='groups'][aria-label]") + end + + @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") + + view + |> 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 + end + + @tag :ui + test "keyboard navigation works for sort header", %{ + conn: conn + } do + conn = conn_with_oidc_user(conn) + {:ok, view, _html} = live(conn, "/members") + + assert has_element?(view, "[data-testid='groups']") + + view + |> element("[data-testid='groups']") + |> render_click() + + # Verify sort was applied (URL may include other params) + assert has_element?(view, "[data-testid='groups'][aria-label*='ascending']") + end + + @tag :ui + test "screen reader announcements for filter changes", %{ + conn: conn, + member1: member1, + group1: group1 + } do + conn = conn_with_oidc_user(conn) + {:ok, view, _html} = live(conn, "/members") + + view + |> 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 + end + + @tag :ui + test "multiple badges are announced correctly", %{ + conn: conn, + member1: member1 + } do + system_actor = Mv.Helpers.SystemActor.get_system_actor() + + # Create multiple groups for member1 + {:ok, group2} = + Group + |> Ash.Changeset.for_create(:create, %{name: "Active Members"}) + |> Ash.create(actor: system_actor) + + {:ok, _mg} = + MemberGroup + |> Ash.Changeset.for_create(:create, %{member_id: member1.id, group_id: group2.id}) + |> Ash.create(actor: system_actor) + + conn = conn_with_oidc_user(conn) + {:ok, _view, html} = live(conn, "/members") + + # Verify multiple badges are present + assert html =~ member1.first_name + # Both groups should be visible + # Screen reader should be able to distinguish between multiple badges + assert html + end +end diff --git a/test/mv_web/member_live/index_groups_display_test.exs b/test/mv_web/member_live/index_groups_display_test.exs new file mode 100644 index 0000000..b28b978 --- /dev/null +++ b/test/mv_web/member_live/index_groups_display_test.exs @@ -0,0 +1,103 @@ +defmodule MvWeb.MemberLive.IndexGroupsDisplayTest do + @moduledoc """ + Tests for displaying groups in the member overview. + + Tests cover: + - Group badges are displayed for members in groups + - Multiple badges for members in multiple groups + - No badge for members without groups + - Badge shows group name correctly + """ + # async: false to prevent PostgreSQL deadlocks when creating members and groups + use MvWeb.ConnCase, async: false + import Phoenix.LiveViewTest + require Ash.Query + + alias Mv.Membership.{Group, MemberGroup} + + setup do + system_actor = Mv.Helpers.SystemActor.get_system_actor() + + {:ok, member1} = + Mv.Membership.create_member( + %{first_name: "Alice", last_name: "Anderson", email: "alice@example.com"}, + actor: system_actor + ) + + {:ok, member2} = + Mv.Membership.create_member( + %{first_name: "Bob", last_name: "Brown", email: "bob@example.com"}, + actor: system_actor + ) + + {:ok, member3} = + Mv.Membership.create_member( + %{first_name: "Charlie", last_name: "Clark", email: "charlie@example.com"}, + actor: system_actor + ) + + {:ok, group1} = + Group + |> Ash.Changeset.for_create(:create, %{name: "Board Members"}) + |> Ash.create(actor: system_actor) + + {:ok, group2} = + Group + |> Ash.Changeset.for_create(:create, %{name: "Active Members"}) + |> Ash.create(actor: system_actor) + + {:ok, _mg1} = + MemberGroup + |> Ash.Changeset.for_create(:create, %{member_id: member1.id, group_id: group1.id}) + |> Ash.create(actor: system_actor) + + {:ok, _mg2} = + MemberGroup + |> Ash.Changeset.for_create(:create, %{member_id: member1.id, group_id: group2.id}) + |> Ash.create(actor: system_actor) + + {:ok, _mg3} = + MemberGroup + |> Ash.Changeset.for_create(:create, %{member_id: member2.id, group_id: group1.id}) + |> Ash.create(actor: system_actor) + + %{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 + conn = conn_with_oidc_user(conn) + {:ok, _view, html} = live(conn, "/members") + + assert html =~ group1.name + assert html =~ group2.name + end + + test "displays multiple badges for member in multiple groups", %{ + conn: conn, + member1: member1, + group1: group1, + group2: group2 + } do + conn = conn_with_oidc_user(conn) + {:ok, _view, html} = live(conn, "/members") + assert html =~ member1.first_name + assert html =~ group1.name + assert html =~ group2.name + end + + test "shows placeholder for members without groups", %{conn: conn, member3: member3} do + conn = conn_with_oidc_user(conn) + {:ok, _view, html} = live(conn, "/members") + assert html =~ member3.first_name + end + + test "displays group name correctly in badge", %{conn: conn, group1: group1} do + conn = conn_with_oidc_user(conn) + {:ok, _view, html} = live(conn, "/members") + assert html =~ group1.name + end +end diff --git a/test/mv_web/member_live/index_groups_filter_test.exs b/test/mv_web/member_live/index_groups_filter_test.exs new file mode 100644 index 0000000..782ab33 --- /dev/null +++ b/test/mv_web/member_live/index_groups_filter_test.exs @@ -0,0 +1,161 @@ +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). Multiple active group filters combine with AND + (member must match all selected group conditions). + """ + # async: false to prevent PostgreSQL deadlocks when creating members and groups + use MvWeb.ConnCase, async: false + import Phoenix.LiveViewTest + require Ash.Query + + alias Mv.Membership.{Group, MemberGroup} + + setup do + system_actor = Mv.Helpers.SystemActor.get_system_actor() + + {:ok, member1} = + Mv.Membership.create_member( + %{first_name: "Alice", last_name: "Anderson", email: "alice@example.com"}, + actor: system_actor + ) + + {:ok, member2} = + Mv.Membership.create_member( + %{first_name: "Bob", last_name: "Brown", email: "bob@example.com"}, + actor: system_actor + ) + + {:ok, member3} = + Mv.Membership.create_member( + %{first_name: "Charlie", last_name: "Clark", email: "charlie@example.com"}, + actor: system_actor + ) + + {:ok, group1} = + Group + |> Ash.Changeset.for_create(:create, %{name: "Board Members"}) + |> Ash.create(actor: system_actor) + + {:ok, group2} = + Group + |> Ash.Changeset.for_create(:create, %{name: "Active Members"}) + |> Ash.create(actor: system_actor) + + {:ok, _mg1} = + MemberGroup + |> Ash.Changeset.for_create(:create, %{member_id: member1.id, group_id: group1.id}) + |> Ash.create(actor: system_actor) + + {:ok, _mg2} = + MemberGroup + |> Ash.Changeset.for_create(:create, %{member_id: member2.id, group_id: group2.id}) + |> Ash.create(actor: system_actor) + + %{member1: member1, member2: member2, member3: member3, group1: group1, group2: group2} + end + + 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 + assert html =~ m2.first_name + assert html =~ m3.first_name + end + + test "filter group1 Yes shows only members 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") + + open_filter_and_set_group(view, group1.id, "in") + + html = render(view) + assert html =~ m1.first_name + refute html =~ m2.first_name + refute html =~ m3.first_name + end + + 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") + + 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") + + html = render(view) + assert html =~ m1.first_name + refute html =~ m2.first_name + refute html =~ m3.first_name + + {: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", %{ + conn: conn, + member1: m1, + member2: m2, + member3: m3, + group1: group1 + } do + conn = conn_with_oidc_user(conn) + {: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 + end +end diff --git a/test/mv_web/member_live/index_groups_integration_test.exs b/test/mv_web/member_live/index_groups_integration_test.exs new file mode 100644 index 0000000..3075d54 --- /dev/null +++ b/test/mv_web/member_live/index_groups_integration_test.exs @@ -0,0 +1,247 @@ +defmodule MvWeb.MemberLive.IndexGroupsIntegrationTest do + @moduledoc """ + Tests for integration of groups with existing features in the member overview. + + Tests cover: + - Groups column works with Field Visibility (column can be hidden) + - Groups filter works with Custom Field filters + - Groups sorting works with other sortings + - Groups work with Membership Fee Status filter + - Groups work with existing search (but not testing search integration itself) + """ + # async: false to prevent PostgreSQL deadlocks when creating members and groups + use MvWeb.ConnCase, async: false + import Phoenix.LiveViewTest + require Ash.Query + + alias Mv.Membership.{Group, MemberGroup, CustomField, CustomFieldValue} + + setup do + system_actor = Mv.Helpers.SystemActor.get_system_actor() + + # Create test members + {:ok, member1} = + Mv.Membership.create_member( + %{first_name: "Alice", last_name: "Anderson", email: "alice@example.com"}, + actor: system_actor + ) + + {:ok, member2} = + Mv.Membership.create_member( + %{first_name: "Bob", last_name: "Brown", email: "bob@example.com"}, + actor: system_actor + ) + + # Create test groups + {:ok, group1} = + Group + |> Ash.Changeset.for_create(:create, %{name: "Board Members"}) + |> Ash.create(actor: system_actor) + + # Create member-group associations + {:ok, _mg1} = + MemberGroup + |> Ash.Changeset.for_create(:create, %{member_id: member1.id, group_id: group1.id}) + |> Ash.create(actor: system_actor) + + # Create custom field for filter integration test + {:ok, custom_field} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "newsletter", + value_type: :boolean, + show_in_overview: false + }) + |> Ash.create(actor: system_actor) + + # Create custom field value for member1 + {:ok, _cfv} = + CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member1.id, + custom_field_id: custom_field.id, + value: %{"_union_type" => "boolean", "_union_value" => true} + }) + |> Ash.create(actor: system_actor) + + %{ + member1: member1, + member2: member2, + group1: group1, + custom_field: custom_field + } + end + + test "groups column works with field visibility", %{ + conn: conn, + member1: member1, + group1: group1 + } do + conn = conn_with_oidc_user(conn) + {:ok, _view, html} = live(conn, "/members") + + # Verify groups column is visible by default + assert html =~ group1.name + assert html =~ member1.first_name + + # Hide groups column via field visibility dropdown + # (This tests integration with field visibility feature) + # Note: Actual implementation depends on how field visibility works + # For now, we verify the column exists and can be toggled + assert html + end + + test "groups filter works with custom field filters", %{ + conn: conn, + member1: member1, + group1: group1 + } do + # Verify group filter applies; boolean filters live in the filter dropdown and + # are exercised in member filter tests. Here we only assert group filter works. + conn = conn_with_oidc_user(conn) + {:ok, view, _html} = live(conn, "/members") + + view + |> 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 + end + + test "groups sorting works with other sortings", %{ + conn: conn, + member1: member1, + member2: member2 + } do + conn = conn_with_oidc_user(conn) + {:ok, view, _html} = live(conn, "/members?sort_field=first_name&sort_order=asc") + + # Apply groups sorting (should combine with existing sort) + view + |> element("[data-testid='groups']") + |> render_click() + + # Verify both sorts are applied (or groups sort replaces first_name sort) + html = render(view) + assert html =~ member1.first_name + assert html =~ member2.first_name + + # Sort by groups was applied (URL may include query= and other default params) + assert has_element?(view, "[data-testid='groups'][aria-label*='ascending']") + end + + test "groups work with membership fee status filter", %{ + conn: conn, + member1: member1, + group1: group1 + } do + system_actor = Mv.Helpers.SystemActor.get_system_actor() + + # Create a membership fee type and cycle for member1 + {:ok, fee_type} = + Mv.MembershipFees.MembershipFeeType + |> Ash.Changeset.for_create(:create, %{ + name: "Test Fee", + amount: Decimal.new("50.00"), + interval: :yearly + }) + |> Ash.create(actor: system_actor) + + # Set member's fee type so get_last_completed_cycle finds the cycle (uses member.membership_fee_type) + {:ok, _member1} = + Mv.Membership.update_member(member1, %{membership_fee_type_id: fee_type.id}, + actor: system_actor + ) + + {:ok, _cycle} = + Mv.MembershipFees.MembershipFeeCycle + |> Ash.Changeset.for_create(:create, %{ + member_id: member1.id, + membership_fee_type_id: fee_type.id, + cycle_start: ~D[2024-01-01], + amount: Decimal.new("50.00"), + status: :paid + }) + |> Ash.create(actor: system_actor) + + conn = conn_with_oidc_user(conn) + + {:ok, _view, html} = + live(conn, "/members?group_#{group1.id}=in&cycle_status_filter=paid") + + assert html =~ "Members" + # 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)", %{ + conn: conn, + member1: member1, + member2: member2, + group1: group1 + } do + conn = conn_with_oidc_user(conn) + {:ok, view, _html} = live(conn, "/members") + + # Apply group filter + view + |> 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") + view + |> element("form[phx-submit='search']") + |> render_submit(%{"query" => "Alice"}) + + # Verify filter and search both work + html = render(view) + assert html =~ member1.first_name + refute html =~ member2.first_name + + # Note: We're not testing that group names are searchable + # (that's part of Issue #5 - Search Integration) + end + + test "all filters and sortings work together", %{ + conn: conn, + member1: member1, + group1: group1 + } do + conn = conn_with_oidc_user(conn) + {:ok, view, _html} = live(conn, "/members") + + # Apply group filter + view + |> 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 + |> element("[data-testid='groups']") + |> render_click() + + # Apply search + view + |> element("form[phx-submit='search']") + |> render_submit(%{"query" => "Alice"}) + + # Verify group filter, sort, and search are all applied + html = render(view) + assert html =~ member1.first_name + assert has_element?(view, "[data-testid='groups'][aria-label*='ascending']") + end +end diff --git a/test/mv_web/member_live/index_groups_performance_test.exs b/test/mv_web/member_live/index_groups_performance_test.exs new file mode 100644 index 0000000..761c4eb --- /dev/null +++ b/test/mv_web/member_live/index_groups_performance_test.exs @@ -0,0 +1,207 @@ +defmodule MvWeb.MemberLive.IndexGroupsPerformanceTest do + @moduledoc """ + Tests for performance and N+1 query prevention for groups in the member overview. + + Tests cover: + - Groups are loaded with members in a single query (preloading) + - No N+1 queries when loading members with groups + - Filter works at database level (not in-memory) + - Sort runs in-memory but uses preloaded group data (no extra DB queries) + """ + # async: false to prevent PostgreSQL deadlocks when creating members and groups + use MvWeb.ConnCase, async: false + import Phoenix.LiveViewTest + require Ash.Query + + alias Mv.Membership.{Group, MemberGroup} + + setup do + system_actor = Mv.Helpers.SystemActor.get_system_actor() + + # Create test members (enough to test performance) + members = + for i <- 1..10 do + {:ok, member} = + Mv.Membership.create_member( + %{ + first_name: "Member#{i}", + last_name: "Test#{i}", + email: "member#{i}@example.com" + }, + actor: system_actor + ) + + member + end + + # Create test groups + {:ok, group1} = + Group + |> Ash.Changeset.for_create(:create, %{name: "Group 1"}) + |> Ash.create(actor: system_actor) + + {:ok, group2} = + Group + |> Ash.Changeset.for_create(:create, %{name: "Group 2"}) + |> Ash.create(actor: system_actor) + + # Assign members to groups (alternating pattern) + Enum.each(Enum.with_index(members), fn {member, index} -> + group_id = if rem(index, 2) == 0, do: group1.id, else: group2.id + + {:ok, _mg} = + MemberGroup + |> Ash.Changeset.for_create(:create, %{member_id: member.id, group_id: group_id}) + |> Ash.create(actor: system_actor) + end) + + %{ + members: members, + group1: group1, + group2: group2 + } + end + + @tag :slow + test "groups are preloaded with members (no N+1 queries)", %{ + conn: conn, + members: _members + } do + # This test verifies that groups are loaded efficiently + # We check query count by monitoring database queries + # Note: Actual query counting would require Ecto query logging + # For now, we verify the functionality works correctly + + conn = conn_with_oidc_user(conn) + {:ok, _view, html} = live(conn, "/members") + + # Verify all members are loaded + Enum.each(1..10, fn i -> + assert html =~ "Member#{i}" + end) + + # Verify groups are displayed (if preloaded correctly, this should work) + # If N+1 queries occurred, the page might be slow or fail + assert html + end + + @tag :slow + test "filter works at database level", %{ + conn: conn, + group1: group1, + members: members + } do + # This test verifies that filtering happens in the database query, + # not by filtering in-memory after loading all members + + conn = conn_with_oidc_user(conn) + {:ok, view, _html} = live(conn, "/members") + + # Open filter and apply "Yes" for group1 (even-indexed members are in group1) + view + |> element("button[aria-label='Filter members']") + |> render_click() + + 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) + + # 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([1, 3, 5, 7, 9], fn i -> + member = Enum.at(members, i) + refute html =~ member.first_name + end) + end + + @tag :slow + test "sorting works at database level", %{ + conn: conn, + members: _members + } do + # This test verifies that sorting happens in the database query, + # not by sorting in-memory after loading all members + + conn = conn_with_oidc_user(conn) + {:ok, view, _html} = live(conn, "/members") + + # Apply sorting + view + |> element("[data-testid='groups']") + |> render_click() + + # Verify sorting is applied + html = render(view) + + # Verify members are displayed (if sorting was done in-memory, + # we'd load all members first, which is less efficient) + assert html + + # Database-level sorting is more efficient for large datasets + end + + @tag :slow + test "handles many members with many groups efficiently", %{ + conn: conn + } do + system_actor = Mv.Helpers.SystemActor.get_system_actor() + + # Create many members (20) with multiple groups each (use distinct emails to avoid collision with setup) + members = + for i <- 11..30 do + {:ok, member} = + Mv.Membership.create_member( + %{ + first_name: "Member#{i}", + last_name: "Test#{i}", + email: "member#{i}@example.com" + }, + actor: system_actor + ) + + member + end + + # Create multiple groups (use distinct names to avoid collision with setup's Group 1/2) + groups = + for i <- 1..5 do + {:ok, group} = + Group + |> Ash.Changeset.for_create(:create, %{name: "Perf Group #{i}"}) + |> Ash.create(actor: system_actor) + + group + end + + # Assign each member to 2-3 random groups + Enum.each(members, fn member -> + selected_groups = Enum.take_random(groups, Enum.random(2..3)) + + Enum.each(selected_groups, fn group -> + {:ok, _mg} = + MemberGroup + |> Ash.Changeset.for_create(:create, %{member_id: member.id, group_id: group.id}) + |> Ash.create(actor: system_actor) + end) + end) + + conn = conn_with_oidc_user(conn) + {:ok, _view, html} = live(conn, "/members") + + # Verify all members are loaded efficiently + Enum.each(11..30, fn i -> + assert html =~ "Member#{i}" + end) + + # If preloading works correctly, this should be fast + # If N+1 queries occurred, this would be very slow + assert html + end +end diff --git a/test/mv_web/member_live/index_groups_sorting_test.exs b/test/mv_web/member_live/index_groups_sorting_test.exs new file mode 100644 index 0000000..068152c --- /dev/null +++ b/test/mv_web/member_live/index_groups_sorting_test.exs @@ -0,0 +1,69 @@ +defmodule MvWeb.MemberLive.IndexGroupsSortingTest do + @moduledoc """ + Tests for sorting by groups in the member overview. + """ + # async: false to prevent PostgreSQL deadlocks when creating members and groups + use MvWeb.ConnCase, async: false + import Phoenix.LiveViewTest + require Ash.Query + + alias Mv.Membership.{Group, MemberGroup} + + setup do + system_actor = Mv.Helpers.SystemActor.get_system_actor() + + {:ok, member1} = + Mv.Membership.create_member( + %{first_name: "Alice", last_name: "Anderson", email: "alice@example.com"}, + actor: system_actor + ) + + {:ok, member2} = + Mv.Membership.create_member( + %{first_name: "Bob", last_name: "Brown", email: "bob@example.com"}, + actor: system_actor + ) + + {:ok, member4} = + Mv.Membership.create_member( + %{first_name: "David", last_name: "Davis", email: "david@example.com"}, + actor: system_actor + ) + + {:ok, group_a} = + Group + |> Ash.Changeset.for_create(:create, %{name: "A Group"}) + |> Ash.create(actor: system_actor) + + {:ok, group_b} = + Group + |> Ash.Changeset.for_create(:create, %{name: "B Group"}) + |> Ash.create(actor: system_actor) + + {:ok, _mg1} = + MemberGroup + |> Ash.Changeset.for_create(:create, %{member_id: member1.id, group_id: group_a.id}) + |> Ash.create(actor: system_actor) + + {:ok, _mg2} = + MemberGroup + |> Ash.Changeset.for_create(:create, %{member_id: member2.id, group_id: group_b.id}) + |> Ash.create(actor: system_actor) + + %{member1: member1, member2: member2, member4: member4, group_a: group_a, group_b: group_b} + end + + test "sorts by group name ascending", %{conn: conn, group_a: group_a} do + conn = conn_with_oidc_user(conn) + {:ok, view, _html} = live(conn, "/members") + + view + |> element("[data-testid='groups']") + |> render_click() + + # Sort was applied: button shows ascending state and group names still visible + assert has_element?(view, "[data-testid='groups']") + html = render(view) + assert html =~ group_a.name + end +end 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 new file mode 100644 index 0000000..469b010 --- /dev/null +++ b/test/mv_web/member_live/index_groups_url_params_test.exs @@ -0,0 +1,185 @@ +defmodule MvWeb.MemberLive.IndexGroupsUrlParamsTest do + @moduledoc """ + Tests for URL parameter persistence for groups in the member overview. + + Tests cover: + - 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.) + - URL is bookmarkable (filter/sorting persist) + """ + # async: false to prevent PostgreSQL deadlocks when creating members and groups + use MvWeb.ConnCase, async: false + import Phoenix.LiveViewTest + require Ash.Query + + alias Mv.Membership.{Group, MemberGroup} + + setup do + system_actor = Mv.Helpers.SystemActor.get_system_actor() + + # Create test members + {:ok, member1} = + Mv.Membership.create_member( + %{first_name: "Alice", last_name: "Anderson", email: "alice@example.com"}, + actor: system_actor + ) + + {:ok, member2} = + Mv.Membership.create_member( + %{first_name: "Bob", last_name: "Brown", email: "bob@example.com"}, + actor: system_actor + ) + + # Create test groups + {:ok, group1} = + Group + |> Ash.Changeset.for_create(:create, %{name: "Board Members"}) + |> Ash.create(actor: system_actor) + + # Create member-group associations + {:ok, _mg1} = + MemberGroup + |> Ash.Changeset.for_create(:create, %{member_id: member1.id, group_id: group1.id}) + |> Ash.create(actor: system_actor) + + %{ + member1: member1, + member2: member2, + group1: group1 + } + end + + 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") + + view + |> 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 + end + + test "group sorting is written to URL", %{ + conn: conn + } do + conn = conn_with_oidc_user(conn) + {:ok, view, _html} = live(conn, "/members") + + # Click on groups column header to sort + view + |> element("[data-testid='groups']") + |> render_click() + + # Verify sort was applied (URL is patched with sort params) + assert has_element?(view, "[data-testid='groups'][aria-label*='ascending']") + end + + test "URL parameters are restored on load", %{ + conn: conn, + member1: member1, + member2: member2, + group1: group1 + } do + conn = conn_with_oidc_user(conn) + + {:ok, view, html} = + live(conn, "/members?group_#{group1.id}=in&sort_field=groups&sort_order=asc") + + assert html =~ member1.first_name + refute html =~ member2.first_name + assert has_element?(view, "[data-testid='groups'][aria-label*='ascending']") + end + + test "URL parameters work with query parameter", %{ + conn: conn, + member1: member1, + group1: group1 + } do + conn = conn_with_oidc_user(conn) + {:ok, _view, html} = live(conn, "/members?query=Alice&group_#{group1.id}=in") + + 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_#{group1.id}=in") + + assert html =~ member1.first_name + assert has_element?(view, "[data-testid='first_name'][aria-label*='descending']") + end + + test "URL is bookmarkable with filter and sorting", %{ + conn: conn, + member1: member1, + group1: group1 + } do + conn = conn_with_oidc_user(conn) + bookmark_url = "/members?group_#{group1.id}=in&sort_field=groups&sort_order=asc" + + {:ok, view, html} = live(conn, bookmark_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)", %{ + conn: conn, + member1: member1, + member2: member2, + group1: group1 + } do + conn = conn_with_oidc_user(conn) + # 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") + + # 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", %{ + conn: conn, + member1: member1, + member2: member2 + } do + conn = conn_with_oidc_user(conn) + invalid_id = Ecto.UUID.generate() + {:ok, _view, html} = live(conn, "/members?group_#{invalid_id}=in") + + # Unknown group id ignored, all members shown + assert html =~ member1.first_name + assert html =~ member2.first_name + end + + test "handles malformed URL parameters", %{ + conn: conn, + member1: member1, + member2: member2 + } do + conn = conn_with_oidc_user(conn) + {:ok, _view, html} = live(conn, "/members?group_not-a-uuid=in") + + assert html =~ member1.first_name + assert html =~ member2.first_name + end +end