diff --git a/docs/groups-architecture.md b/docs/groups-architecture.md index 27d9d18..0e59409 100644 --- a/docs/groups-architecture.md +++ b/docs/groups-architecture.md @@ -356,9 +356,9 @@ lib/ - Screen readers must be able to navigate and understand the interface - ARIA labels and roles must be properly set -**Group Badges in Member Overview:** -- Badges must have `role="status"` and appropriate `aria-label` attributes -- Badge title should indicate group membership +**Group Badges and Links in Member Overview / Detail:** +- Use `aria-label` to indicate group membership (e.g. "Member of group X"). Do not use `role="status"` on badges or links—that role is for live regions (screen reader announcements), not for navigation or static labels. +- Badge/link text or title should indicate group membership for screen readers. **Clickable Group Badge (for filtering) - Optional:** @@ -961,6 +961,8 @@ Each functional unit can be implemented as a **separate issue**: ### Issue 4: Member Detail - Groups Display **Type:** Frontend **Estimation:** 1-2h +**Status:** Implemented (Groups as data field in Personal Data, below Linked User; button-style links to `/groups/:slug`). + **Tasks:** - Add groups section to member show - Display group badges diff --git a/lib/mv_web/live/member_live/index.html.heex b/lib/mv_web/live/member_live/index.html.heex index 311447b..b490618 100644 --- a/lib/mv_web/live/member_live/index.html.heex +++ b/lib/mv_web/live/member_live/index.html.heex @@ -322,7 +322,6 @@ <%= for group <- (member.groups || []) do %> {group.name} diff --git a/lib/mv_web/live/member_live/show.ex b/lib/mv_web/live/member_live/show.ex index 40491cc..47e8878 100644 --- a/lib/mv_web/live/member_live/show.ex +++ b/lib/mv_web/live/member_live/show.ex @@ -12,6 +12,8 @@ defmodule MvWeb.MemberLive.Show do ## Sections - Personal Data: Name, address, contact information, membership dates, notes - Custom Fields: Dynamic fields in uniform grid layout (sorted by name) + - Groups: Links to group detail pages in Personal Data section + - Payment Data: Membership fee type and cycle status - Membership Fees: Tab showing all membership fee cycles with status management (via MembershipFeesComponent) ## Navigation @@ -146,6 +148,28 @@ defmodule MvWeb.MemberLive.Show do <% end %> + <%!-- Groups (in Personal Data) --%> + <% groups = @member.groups || [] %> +
+ <.data_field label={gettext("Groups")}> + <%= if Enum.empty?(groups) do %> + {gettext("No groups")} + <% else %> +
+ <%= for group <- groups do %> + <.link + navigate={~p"/groups/#{group.slug}"} + class="btn btn-xs btn-outline btn-primary" + aria-label={gettext("Member of group %{name}", name: group.name)} + > + {group.name} + + <% end %> +
+ <% end %> + +
+ <%!-- Notes --%> <%= if @member.notes && String.trim(@member.notes) != "" do %>
@@ -262,7 +286,8 @@ defmodule MvWeb.MemberLive.Show do :user, :membership_fee_type, custom_field_values: [:custom_field], - membership_fee_cycles: [:membership_fee_type] + membership_fee_cycles: [:membership_fee_type], + groups: [:id, :name, :slug] ]) member = Ash.read_one!(query, actor: actor) diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po index 1784d4b..e40d053 100644 --- a/priv/gettext/de/LC_MESSAGES/default.po +++ b/priv/gettext/de/LC_MESSAGES/default.po @@ -2202,11 +2202,13 @@ msgstr "Gruppe erfolgreich gespeichert." #: 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 +#: lib/mv_web/live/member_live/show.ex #, elixir-autogen, elixir-format msgid "Groups" msgstr "Gruppen" #: lib/mv_web/live/group_live/index.ex +#: lib/mv_web/live/member_live/show.ex #, elixir-autogen, elixir-format msgid "No groups" msgstr "Keine Gruppen" @@ -2474,6 +2476,7 @@ msgid "unpaid" msgstr "Unbezahlt" #: lib/mv_web/live/member_live/index.html.heex +#: lib/mv_web/live/member_live/show.ex #, elixir-autogen, elixir-format msgid "Member of group %{name}" msgstr "Mitglied der Gruppe %{name}" @@ -2613,12 +2616,3 @@ msgstr "Anzahl Mitglieder:" #, elixir-autogen, elixir-format msgid "PDF" msgstr "PDF" - -#~ #: lib/mv_web/live/global_settings_live.ex -#~ #, elixir-autogen, elixir-format, fuzzy -#~ msgid "Custom Fields in CSV Import" -#~ msgstr "Benutzerdefinierte Felder" - -#~ #, elixir-autogen, elixir-format -#~ msgid "Failed to prepare CSV import: %{error}" -#~ msgstr "Das Vorbereiten des CSV Imports ist gescheitert: %{error}" diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot index af24afd..fb156df 100644 --- a/priv/gettext/default.pot +++ b/priv/gettext/default.pot @@ -2203,11 +2203,13 @@ msgstr "" #: 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 +#: lib/mv_web/live/member_live/show.ex #, elixir-autogen, elixir-format msgid "Groups" msgstr "" #: lib/mv_web/live/group_live/index.ex +#: lib/mv_web/live/member_live/show.ex #, elixir-autogen, elixir-format msgid "No groups" msgstr "" @@ -2475,6 +2477,7 @@ msgid "unpaid" msgstr "" #: lib/mv_web/live/member_live/index.html.heex +#: lib/mv_web/live/member_live/show.ex #, 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 88da6ff..f12d478 100644 --- a/priv/gettext/en/LC_MESSAGES/default.po +++ b/priv/gettext/en/LC_MESSAGES/default.po @@ -2203,11 +2203,13 @@ msgstr "" #: 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 +#: lib/mv_web/live/member_live/show.ex #, elixir-autogen, elixir-format msgid "Groups" msgstr "" #: lib/mv_web/live/group_live/index.ex +#: lib/mv_web/live/member_live/show.ex #, elixir-autogen, elixir-format msgid "No groups" msgstr "" @@ -2475,6 +2477,7 @@ msgid "unpaid" msgstr "" #: lib/mv_web/live/member_live/index.html.heex +#: lib/mv_web/live/member_live/show.ex #, elixir-autogen, elixir-format msgid "Member of group %{name}" msgstr "" @@ -2614,8 +2617,3 @@ msgstr "Member count:" #, elixir-autogen, elixir-format msgid "PDF" msgstr "" - -#~ #: lib/mv_web/live/global_settings_live.ex -#~ #, elixir-autogen, elixir-format, fuzzy -#~ msgid "Custom Fields in CSV Import" -#~ 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 ab9b728..d14cd9f 100644 --- a/test/mv_web/member_live/index_groups_accessibility_test.exs +++ b/test/mv_web/member_live/index_groups_accessibility_test.exs @@ -3,7 +3,7 @@ defmodule MvWeb.MemberLive.IndexGroupsAccessibilityTest do Tests for accessibility of groups feature in the member overview. Tests cover: - - Badges have role="status" and aria-label + - Badges have aria-label for group membership (no role="status"; reserved for live regions) - Filter dropdown has aria-label - Sort header has aria-label for screen reader - Keyboard navigation works (Tab through filter, sort header) @@ -44,7 +44,7 @@ defmodule MvWeb.MemberLive.IndexGroupsAccessibilityTest do end @tag :ui - test "group badges have role and aria-label", %{ + test "group badges have aria-label for screen readers", %{ conn: conn, member1: member1, group1: group1 @@ -52,8 +52,8 @@ defmodule MvWeb.MemberLive.IndexGroupsAccessibilityTest 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}']") + # Verify badges have aria-label containing the group name (no role=status on badges) + assert has_element?(view, "span[aria-label*='#{group1.name}']") assert html =~ group1.name # Verify member1's row contains the badge diff --git a/test/mv_web/member_live/show_groups_display_test.exs b/test/mv_web/member_live/show_groups_display_test.exs new file mode 100644 index 0000000..f8434b3 --- /dev/null +++ b/test/mv_web/member_live/show_groups_display_test.exs @@ -0,0 +1,262 @@ +defmodule MvWeb.MemberLive.ShowGroupsDisplayTest do + @moduledoc """ + Tests for displaying groups in the member detail view (Issue #374). + + Tests cover: + - Groups in Personal Data (with and without groups) + - Group links with correct names and links to group detail pages + - Edge cases (one group, many groups) + - Security: groups visible only when user may view member + - Accessibility: group links have aria-label for screen readers + + ## Note on async + async: false to avoid PostgreSQL deadlocks when creating members and groups + in the same test run (same as IndexGroupsDisplayTest). + """ + use MvWeb.ConnCase, async: false + import Phoenix.LiveViewTest + require Ash.Query + use Gettext, backend: MvWeb.Gettext + + alias Mv.Membership.{Group, MemberGroup} + + describe "groups section" do + setup do + actor = Mv.Helpers.SystemActor.get_system_actor() + + {:ok, member} = + create_member(actor, %{ + first_name: "Alice", + last_name: "Anderson", + email: "alice@example.com" + }) + + %{member: member, actor: actor} + end + + test "displays Groups section when member has at least one group", %{ + conn: conn, + member: member, + actor: actor + } do + {:ok, group} = create_group(actor, "Board Members") + {:ok, _mg} = add_member_to_group(member, group, actor) + + conn = conn_with_oidc_user(conn) + {:ok, _view, html} = live(conn, ~p"/members/#{member}") + + assert html =~ gettext("Groups") + assert html =~ group.name + end + + test "displays all group links with correct names when member is in multiple groups", %{ + conn: conn, + member: member, + actor: actor + } do + {:ok, group1} = create_group(actor, "Board Members") + {:ok, group2} = create_group(actor, "Active Members") + {:ok, _mg1} = add_member_to_group(member, group1, actor) + {:ok, _mg2} = add_member_to_group(member, group2, actor) + + conn = conn_with_oidc_user(conn) + {:ok, _view, html} = live(conn, ~p"/members/#{member}") + + assert html =~ gettext("Groups") + assert html =~ group1.name + assert html =~ group2.name + end + + test "displays Groups section when member has no groups (empty state)", %{ + conn: conn, + member: member + } do + conn = conn_with_oidc_user(conn) + {:ok, _view, html} = live(conn, ~p"/members/#{member}") + + # Groups are in Personal Data; label "Groups" and empty state "No groups" must be present + assert html =~ gettext("Groups") + assert html =~ gettext("No groups") + end + + test "renders all group names when member has multiple groups", %{ + conn: conn, + member: member, + actor: actor + } do + {:ok, group1} = create_group(actor, "Alpha") + {:ok, group2} = create_group(actor, "Beta") + {:ok, _mg1} = add_member_to_group(member, group1, actor) + {:ok, _mg2} = add_member_to_group(member, group2, actor) + + conn = conn_with_oidc_user(conn) + {:ok, _view, html} = live(conn, ~p"/members/#{member}") + + assert html =~ "Alpha" + assert html =~ "Beta" + end + end + + describe "groups section links" do + setup do + actor = Mv.Helpers.SystemActor.get_system_actor() + + {:ok, member} = + create_member(actor, %{first_name: "Bob", last_name: "Brown", email: "bob@example.com"}) + + {:ok, group} = create_group(actor, "Board Members") + {:ok, _mg} = add_member_to_group(member, group, actor) + %{member: member, group: group} + end + + test "each group link goes to group detail page with correct slug", %{ + conn: conn, + member: member, + group: group + } do + conn = conn_with_oidc_user(conn) + {:ok, view, _html} = live(conn, ~p"/members/#{member}") + + # Link to group detail: /groups/:slug (slug is URL-friendly, e.g. "board-members") + assert has_element?(view, "a[href*='/groups/#{group.slug}']", group.name) + end + + test "clicking group link navigates to group detail page", %{ + conn: conn, + member: member, + group: group + } do + conn = conn_with_oidc_user(conn) + {:ok, view, _html} = live(conn, ~p"/members/#{member}") + + view + |> element("a[href*='/groups/#{group.slug}']") + |> render_click() + + assert_redirect(view, ~p"/groups/#{group.slug}") + end + end + + describe "groups section edge cases" do + setup do + actor = Mv.Helpers.SystemActor.get_system_actor() + + {:ok, member} = + create_member(actor, %{ + first_name: "Charlie", + last_name: "Clark", + email: "charlie@example.com" + }) + + %{member: member, actor: actor} + end + + test "member in exactly one group shows single link", %{ + conn: conn, + member: member, + actor: actor + } do + {:ok, group} = create_group(actor, "Solo Group") + {:ok, _mg} = add_member_to_group(member, group, actor) + + conn = conn_with_oidc_user(conn) + {:ok, _view, html} = live(conn, ~p"/members/#{member}") + + assert html =~ gettext("Groups") + assert html =~ group.name + end + + test "member in many groups shows all group links", %{ + conn: conn, + member: member, + actor: actor + } do + group_names = Enum.map(1..5, fn i -> "Group #{i}" end) + + groups = + Enum.map(group_names, fn name -> + {:ok, g} = create_group(actor, name) + g + end) + + for g <- groups do + {:ok, _mg} = add_member_to_group(member, g, actor) + end + + conn = conn_with_oidc_user(conn) + {:ok, _view, html} = live(conn, ~p"/members/#{member}") + + assert html =~ gettext("Groups") + + for name <- group_names do + assert html =~ name + end + end + end + + describe "groups section with read_only user" do + @tag role: :read_only + test "user with read permission sees Groups section", %{conn: conn} do + actor = Mv.Helpers.SystemActor.get_system_actor() + + {:ok, member} = + create_member(actor, %{ + first_name: "Diana", + last_name: "Davis", + email: "diana@example.com" + }) + + {:ok, group} = create_group(actor, "Readers") + {:ok, _mg} = add_member_to_group(member, group, actor) + + {:ok, _view, html} = live(conn, ~p"/members/#{member}") + + assert html =~ gettext("Groups") + assert html =~ group.name + end + end + + describe "groups section accessibility" do + setup do + actor = Mv.Helpers.SystemActor.get_system_actor() + + {:ok, member} = + create_member(actor, %{first_name: "Eve", last_name: "Evans", email: "eve@example.com"}) + + {:ok, group} = create_group(actor, "A11y Group") + {:ok, _mg} = add_member_to_group(member, group, actor) + %{member: member, group: group} + end + + test "group links have aria-label for screen readers", %{ + conn: conn, + member: member, + group: group + } do + conn = conn_with_oidc_user(conn) + {:ok, view, html} = live(conn, ~p"/members/#{member}") + + assert html =~ group.name + + # Group link has aria-label indicating group membership for screen readers + assert has_element?(view, "a[aria-label*='#{group.name}']") + end + end + + # Helpers to reduce setup duplication (create member/group, assign member to group). + defp create_member(actor, attrs) do + Mv.Membership.create_member(attrs, actor: actor) + end + + defp create_group(actor, name) do + Group + |> Ash.Changeset.for_create(:create, %{name: name}) + |> Ash.create(actor: actor) + end + + defp add_member_to_group(member, group, actor) do + MemberGroup + |> Ash.Changeset.for_create(:create, %{member_id: member.id, group_id: group.id}) + |> Ash.create(actor: actor) + end +end