diff --git a/lib/mv_web/live/member_live/index.ex b/lib/mv_web/live/member_live/index.ex index 881be53..41f08c9 100644 --- a/lib/mv_web/live/member_live/index.ex +++ b/lib/mv_web/live/member_live/index.ex @@ -85,6 +85,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 +121,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_filter, nil) + |> assign(:groups, groups) |> assign(:boolean_custom_field_filters, %{}) |> assign(:selected_members, MapSet.new()) |> assign(:settings, settings) @@ -242,6 +250,7 @@ defmodule MvWeb.MemberLive.Index do socket.assigns.sort_field, socket.assigns.sort_order, socket.assigns.cycle_status_filter, + socket.assigns[:group_filter], new_show_current, socket.assigns.boolean_custom_field_filters ) @@ -255,6 +264,35 @@ 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 @@ -352,6 +390,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.show_current_cycle, socket.assigns.boolean_custom_field_filters ) @@ -377,6 +416,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.show_current_cycle, socket.assigns.boolean_custom_field_filters ) @@ -404,6 +444,7 @@ defmodule MvWeb.MemberLive.Index do socket.assigns.sort_field, socket.assigns.sort_order, filter, + socket.assigns[:group_filter], socket.assigns.show_current_cycle, socket.assigns.boolean_custom_field_filters ) @@ -437,6 +478,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.show_current_cycle, updated_filters ) @@ -454,6 +496,7 @@ defmodule MvWeb.MemberLive.Index do socket = socket |> assign(:cycle_status_filter, cycle_status_filter) + |> assign(:group_filter, nil) |> assign(:boolean_custom_field_filters, boolean_filters) |> load_members() |> update_selection_assigns() @@ -464,6 +507,7 @@ defmodule MvWeb.MemberLive.Index do socket.assigns.sort_field, socket.assigns.sort_order, cycle_status_filter, + socket.assigns[:group_filter], socket.assigns.show_current_cycle, boolean_filters ) @@ -600,6 +644,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_boolean_filters(params) |> maybe_update_show_current_cycle(params) |> assign(:fields_in_url?, fields_in_url?) @@ -633,6 +678,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.show_current_cycle, socket.assigns.boolean_custom_field_filters, socket.assigns.user_field_selection, @@ -726,6 +772,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.show_current_cycle, socket.assigns.boolean_custom_field_filters ) @@ -744,6 +791,7 @@ defmodule MvWeb.MemberLive.Index do sort_field, sort_order, cycle_status_filter, + group_filter, show_current_cycle, boolean_filters ) do @@ -774,6 +822,13 @@ defmodule MvWeb.MemberLive.Index do :unpaid -> Map.put(base_params, "cycle_status_filter", "unpaid") end + base_params = + if group_filter && group_filter != "" do + Map.put(base_params, "group_filter", to_string(group_filter)) + else + base_params + end + base_params = if show_current_cycle do Map.put(base_params, "show_current_cycle", "true") @@ -823,8 +878,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_filter(query, socket.assigns[:group_filter]) + # Use ALL custom fields for sorting (not just show_in_overview subset) custom_fields_for_sort = socket.assigns.all_custom_fields @@ -860,7 +921,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 @@ -902,6 +963,13 @@ 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_filter(query, group_id) when is_binary(group_id) do + Ash.Query.filter(query, expr(exists(groups, id == ^group_id))) + end + defp apply_cycle_status_filter(members, nil, _show_current), do: members defp apply_cycle_status_filter(members, status, show_current) @@ -937,6 +1005,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} @@ -976,11 +1048,12 @@ 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 + field == "groups" or + custom_field_sort?(field) or ((atom = safe_member_field_atom_only(field)) != nil and valid_sort_field_db_or_custom?(atom)) end @@ -1024,14 +1097,37 @@ 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) + cond do + field in [:groups, "groups"] -> + sort_members_by_groups(members, order) - case custom_field_id_str do - nil -> members - id_str -> sort_members_by_custom_field(members, id_str, order, custom_fields) + 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 end end + defp sort_members_by_groups(members, order) do + # Members with groups first, then by first group name alphabetically + first_group_name = fn member -> + groups = member.groups || [] + names = Enum.map(groups, & &1.name) |> Enum.sort() + List.first(names) + 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) @@ -1126,11 +1222,12 @@ 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 @@ -1160,6 +1257,32 @@ 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) + end + + defp maybe_update_group_filter(socket, _params), do: socket + + defp normalize_group_filter("", _groups), do: nil + defp normalize_group_filter(nil, _groups), do: nil + + 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 + end + end + + defp normalize_group_filter(_, _), 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 381cd63..b72e598 100644 --- a/lib/mv_web/live/member_live/index.html.heex +++ b/lib/mv_web/live/member_live/index.html.heex @@ -52,6 +52,22 @@ query={@query} placeholder={gettext("Search...")} /> +
+ +
<.live_component module={MvWeb.Components.MemberFilterComponent} id="member-filter" @@ -310,6 +326,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/mix.lock b/mix.lock index f698fa5..98e8726 100644 --- a/mix.lock +++ b/mix.lock @@ -72,7 +72,7 @@ "sobelow": {:hex, :sobelow, "0.14.1", "2f81e8632f15574cba2402bcddff5497b413c01e6f094bc0ab94e83c2f74db81", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "8fac9a2bd90fdc4b15d6fca6e1608efb7f7c600fa75800813b794ee9364c87f2"}, "sourceror": {:hex, :sourceror, "1.10.1", "325753ed460fe9fa34ebb4deda76d57b2e1507dcd78a5eb9e1c41bfb78b7cdfe", [:mix], [], "hexpm", "288f3079d93865cd1e3e20df5b884ef2cb440e0e03e8ae393624ee8a770ba588"}, "spark": {:hex, :spark, "2.4.0", "f93d3ae6b5f3004e956d52f359fa40670366685447631bc7c058f4fbf250ebf3", [:mix], [{:igniter, ">= 0.3.64 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: true]}, {:sourceror, "~> 1.2", [hex: :sourceror, repo: "hexpm", optional: true]}], "hexpm", "4e5185f5737cd987bb9ef377ae3462a55b8312f5007c2bc4ad6e850d14ac0111"}, - "spitfire": {:hex, :spitfire, "0.3.3", "be195b27648f21454932bf46014455cdbce4fca55fef1f0e41d36076c47b6c4a", [], [], "hexpm", "5dc51c3b61a1d98cdcac1c130f0a374d22d51beed982df90834bdd616356e1fa"}, + "spitfire": {:hex, :spitfire, "0.3.3", "be195b27648f21454932bf46014455cdbce4fca55fef1f0e41d36076c47b6c4a", [:mix], [], "hexpm", "5dc51c3b61a1d98cdcac1c130f0a374d22d51beed982df90834bdd616356e1fa"}, "splode": {:hex, :splode, "0.3.0", "ff8effecc509a51245df2f864ec78d849248647c37a75886033e3b1a53ca9470", [:mix], [], "hexpm", "73cfd0892d7316d6f2c93e6e8784bd6e137b2aa38443de52fd0a25171d106d81"}, "stream_data": {:hex, :stream_data, "1.2.0", "58dd3f9e88afe27dc38bef26fce0c84a9e7a96772b2925c7b32cd2435697a52b", [:mix], [], "hexpm", "eb5c546ee3466920314643edf68943a5b14b32d1da9fe01698dc92b73f89a9ed"}, "sweet_xml": {:hex, :sweet_xml, "0.7.5", "803a563113981aaac202a1dbd39771562d0ad31004ddbfc9b5090bdcd5605277", [:mix], [], "hexpm", "193b28a9b12891cae351d81a0cead165ffe67df1b73fe5866d10629f4faefb12"}, 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..120d3ae --- /dev/null +++ b/test/mv_web/member_live/index_groups_display_test.exs @@ -0,0 +1,97 @@ +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 + """ + 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..fb5cce9 --- /dev/null +++ b/test/mv_web/member_live/index_groups_filter_test.exs @@ -0,0 +1,113 @@ +defmodule MvWeb.MemberLive.IndexGroupsFilterTest do + @moduledoc """ + Tests for filtering members by group in the member overview. + """ + 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 + + test "filter 'All groups' 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 by specific group shows only members in that group", %{ + 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}) + + html = render(view) + assert html =~ m1.first_name + refute html =~ m2.first_name + refute html =~ m3.first_name + end + + test "filter persists in URL parameters", %{conn: conn, group1: group1, member1: m1} do + conn = conn_with_oidc_user(conn) + {:ok, view, _html} = live(conn, "/members") + + view + |> element("#group-filter-form") + |> render_change(%{"group_filter" => group1.id}) + + # Verify filter is applied + html = render(view) + assert html =~ m1.first_name + + # Verify visiting with group_filter in URL shows same filtered list + {:ok, _view2, html2} = live(conn, "/members?group_filter=#{group1.id}") + assert html2 =~ m1.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_filter=#{group1.id}") + 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_sorting_test.exs b/test/mv_web/member_live/index_groups_sorting_test.exs new file mode 100644 index 0000000..204caf3 --- /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. + """ + 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