Merge pull request 'Collection of small UI Improvements closes #511' (#527) from issue/mitgliederverwaltung-511 into main
All checks were successful
continuous-integration/drone/push Build is passing

Reviewed-on: #527
This commit is contained in:
moritz 2026-06-15 15:44:58 +02:00
commit d6c322fd79
20 changed files with 2049 additions and 1390 deletions

View file

@ -15,6 +15,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **CSV import membership fee type column** A `Fee Type`/`Beitragsart` column assigns each member's membership fee type; an unknown name falls back to the default fee type and is flagged in the preview with a link to create it.
- **CSV import mapping preview** After uploading a file, a preview shows how every column maps (with sample rows and warnings for ignored or unknown columns) and the import only starts once you confirm.
- **Dynamic CSV import templates** The EN and DE import-template downloads now include the association's current custom fields instead of a fixed column set.
- **Deactivate and reactivate members** Members can be deactivated directly from the member page: a dialog picks the exit date (prefilled to today, future dates allowed); deactivated members can be reactivated, which clears the exit date.
- **Tooltips and OIDC explanation** Icon-only action buttons (including the Vereinfacht sync control) now carry tooltips and accessible labels, and the OIDC settings section explains that it enables single sign-on.
### Changed
- **Member bulk actions in one menu** The actions above the member overview (open in email program, copy email addresses, export to CSV, export to PDF) are now collected in a single "Aktionen" dropdown instead of separate buttons. Without a selection they apply to all members, or to the currently filtered members; the trigger shows the active scope. Opening the email program is disabled when too many recipients are selected, with a hint to copy the addresses or use the export instead.
@ -24,6 +26,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Fixed
- **CSV date round-trip** Date custom-field values are now exported as ISO-8601 (`YYYY-MM-DD`), so an exported CSV can be re-imported without date-parsing errors.
- **CSV import fee-status columns ignored** Columns such as `Bezahlstatus` / `Membership Fee Status` are always ignored on import and never stored as a custom-field value, even when a custom field of the same name exists.
- **Column-header tooltips clipped** Tooltips on the members-overview column headers are no longer clipped by the sticky table header.
- **Text selection opens member** Dragging to select text in a members-overview row (for example to copy an email) no longer opens the member details; a plain click still opens them.
## [1.2.0] - 2026-05-08

View file

@ -103,6 +103,29 @@ Hooks.TableRowKeydown = {
}
}
// RowSelectionGuard: distinguish drag-to-select-text from a plain click on the members table.
// LiveView fires the row navigation push (select_row_and_navigate) on any click. When the user
// drags across a cell to select text (e.g. an email to copy) and releases, the mouseup produces a
// non-empty text selection; in that case we swallow the click in the capture phase so navigation is
// suppressed. A plain click leaves the selection collapsed and navigates as before.
Hooks.RowSelectionGuard = {
mounted() {
this.handleClickCapture = (e) => {
const selection = window.getSelection()
if (selection && !selection.isCollapsed && selection.toString().trim() !== "") {
e.preventDefault()
e.stopPropagation()
}
}
// Capture phase so this runs before LiveView's bubbling phx-click handler.
this.el.addEventListener("click", this.handleClickCapture, true)
},
destroyed() {
this.el.removeEventListener("click", this.handleClickCapture, true)
}
}
// FocusRestore hook: WCAG 2.4.3 — when a modal closes, focus returns to the trigger element (e.g. "Delete member" button)
Hooks.FocusRestore = {
mounted() {

View file

@ -1160,17 +1160,21 @@ defmodule MvWeb.CoreComponents do
end
# Combines column class with optional sticky header classes (desktop only; theme-friendly bg).
# hover:z-20/focus-within:z-20 lift the active header cell above sibling sticky cells (shared z-10)
# so its hover tooltip bubble is not clipped by an adjacent opaque bg-base-100 header background.
defp table_th_class(col, sticky_header) do
base = Map.get(col, :class)
sticky = if sticky_header, do: "lg:sticky lg:top-0 bg-base-100 z-10", else: nil
sticky = if sticky_header, do: sticky_th_classes(), else: nil
[base, sticky] |> Enum.filter(& &1) |> Enum.join(" ")
end
defp table_th_sticky_class(true),
do: "lg:sticky lg:top-0 bg-base-100 z-10"
defp table_th_sticky_class(true), do: sticky_th_classes()
defp table_th_sticky_class(_), do: nil
defp sticky_th_classes,
do: "lg:sticky lg:top-0 bg-base-100 z-10 hover:z-20 focus-within:z-20"
@doc """
Renders a reorderable table (sortable list) with drag handle and keyboard support.

View file

@ -0,0 +1,34 @@
defmodule MvWeb.Helpers.AshErrorHelpers do
@moduledoc """
Shared formatting for Ash errors surfaced as flash messages in the
member show LiveComponents.
Centralizes the translation of `Ash.Error.Invalid` / `Ash.Error.Forbidden`
(and plain string/unknown errors) into user-facing text so the components do
not each carry their own copy.
"""
use Gettext, backend: MvWeb.Gettext
@doc """
Turns an Ash error into a human-readable, localized string.
- `Ash.Error.Invalid` joins the individual error messages, falling back to
`inspect/1` for sub-errors that carry no `:message`.
- `Ash.Error.Forbidden` a localized "not allowed" message.
- a binary passed through unchanged (already a ready-to-show message).
- anything else a localized generic error message.
"""
def format_error(%Ash.Error.Invalid{errors: errors}) do
Enum.map_join(errors, ", ", fn
%{message: message} -> message
other -> inspect(other)
end)
end
def format_error(%Ash.Error.Forbidden{}) do
gettext("You are not allowed to perform this action.")
end
def format_error(error) when is_binary(error), do: error
def format_error(_error), do: gettext("An error occurred")
end

View file

@ -586,15 +586,25 @@ defmodule MvWeb.GlobalSettingsLive do
>
{gettext("Test Integration")}
</.button>
<.button
<.tooltip
:if={Mv.Config.vereinfacht_configured?()}
type="button"
variant="secondary"
phx-click="sync_vereinfacht_contacts"
phx-disable-with={gettext("Syncing...")}
content={
gettext(
"Creates a Vereinfacht finance contact for every member that does not have one yet."
)
}
position="top"
>
{gettext("Sync all members without Vereinfacht contact")}
</.button>
<.button
type="button"
variant="secondary"
phx-click="sync_vereinfacht_contacts"
phx-disable-with={gettext("Syncing...")}
aria-label={gettext("Sync all members without Vereinfacht contact")}
>
{gettext("Sync all members without Vereinfacht contact")}
</.button>
</.tooltip>
</div>
<%= if @vereinfacht_test_result do %>
<.vereinfacht_test_result result={@vereinfacht_test_result} />
@ -646,6 +656,11 @@ defmodule MvWeb.GlobalSettingsLive do
</div>
<h3 class="font-medium mb-3">{gettext("OIDC (Single Sign-On)")}</h3>
<p data-testid="oidc-sso-description" class="text-sm text-base-content/70 mb-4">
{gettext(
"OIDC enables Single Sign-On: once configured, members sign in through your identity provider instead of a separate Mila password."
)}
</p>
<%= if @oidc_env_configured do %>
<p class="text-sm text-base-content/70 mb-4">
{gettext("Some values are set via environment variables. Those fields are read-only.")}

View file

@ -250,17 +250,19 @@ defmodule MvWeb.GroupLive.Show do
<% end %>
</div>
</form>
<.button
type="button"
variant="primary"
phx-click="add_selected_members"
data-testid="group-show-add-selected-members-btn"
disabled={Enum.empty?(@selected_member_ids)}
aria-label={gettext("Add members")}
class="join-item"
>
<.icon name="hero-plus" class="size-5" />
</.button>
<.tooltip content={gettext("Add members")} position="top">
<.button
type="button"
variant="primary"
phx-click="add_selected_members"
data-testid="group-show-add-selected-members-btn"
disabled={Enum.empty?(@selected_member_ids)}
aria-label={gettext("Add members")}
class="join-item"
>
<.icon name="hero-plus" class="size-5" />
</.button>
</.tooltip>
<.button
type="button"
variant="neutral"

View file

@ -82,6 +82,8 @@
<%!-- On desktop (lg:), only the table area scrolls; header and filters stay visible. On mobile, normal flow. --%>
<div
id="members-table-guard"
phx-hook="RowSelectionGuard"
class="lg:max-h-[calc(100vh-14rem)] lg:overflow-auto min-h-0"
data-testid="members-table-scroll"
role="region"

View file

@ -329,6 +329,14 @@ defmodule MvWeb.MemberLive.Show do
</div>
<% end %>
<%!-- Deactivate/reactivate sub-flow (gated on :update, owns its own modal) --%>
<.live_component
module={MvWeb.MemberLive.Show.DeactivateComponent}
id="member-deactivate"
member={@member}
current_user={@current_user}
/>
<%!-- Danger zone: same section pattern as section_box (h2 outside border) --%>
<%= if can?(@current_user, :destroy, @member) do %>
<section class="mt-8 mb-6" aria-labelledby="danger-zone-heading">

View file

@ -0,0 +1,191 @@
defmodule MvWeb.MemberLive.Show.DeactivateComponent do
@moduledoc """
LiveComponent owning the member deactivate/reactivate sub-flow on the member show page.
## Features
- Deactivate control (shown when the member has no exit_date)
- Reactivate control (shown when the member has an exit_date)
- Date-selection modal (default: today, future dates allowed) for deactivation
- Routes both actions through `Membership.update_member/2` with the real user actor,
inheriting the `exit_date > join_date` validation and the cycle-regeneration hook.
Controls are gated on `:update` permission for the member. On success the component
notifies the parent with `{:member_updated, member}`, which the parent already handles.
"""
use MvWeb, :live_component
import MvWeb.Authorization, only: [can?: 3]
import MvWeb.Helpers.AshErrorHelpers, only: [format_error: 1]
alias Mv.Membership
alias MvWeb.Helpers.MemberHelpers
@impl true
def render(assigns) do
~H"""
<div id={@id}>
<%= if @can_update do %>
<section class="mt-8 mb-6" aria-labelledby="membership-state-heading">
<h2 id="membership-state-heading" class="text-lg font-semibold mb-3">
{gettext("Membership status")}
</h2>
<div class="border border-base-300 rounded-lg p-4 bg-base-100">
<%= if @member.exit_date do %>
<p class="text-base-content/70 mb-4">
{gettext(
"This member is deactivated (exit date set). Reactivating clears the exit date."
)}
</p>
<.button
id="reactivate-member-trigger"
data-testid="member-reactivate"
variant="primary"
phx-click="reactivate"
phx-target={@myself}
aria-label={
gettext("Reactivate member %{name}", name: MemberHelpers.display_name(@member))
}
>
<.icon name="hero-arrow-uturn-left" class="size-4" />
{gettext("Reactivate member")}
</.button>
<% else %>
<p class="text-base-content/70 mb-4">
{gettext(
"Deactivating this member records an exit date. You can reactivate them later."
)}
</p>
<.button
id="deactivate-member-trigger"
data-testid="member-deactivate"
variant="outline"
phx-click="open_modal"
phx-target={@myself}
aria-label={
gettext("Deactivate member %{name}", name: MemberHelpers.display_name(@member))
}
>
<.icon name="hero-arrow-right-on-rectangle" class="size-4" />
{gettext("Deactivate member")}
</.button>
<% end %>
</div>
</section>
<% end %>
<%= if @show_modal do %>
<dialog
id="deactivate-member-modal"
class="modal modal-open"
role="dialog"
aria-labelledby="deactivate-member-modal-title"
phx-keydown="dialog_keydown"
phx-target={@myself}
>
<div class="modal-box">
<h3 id="deactivate-member-modal-title" class="text-lg font-bold">
{gettext("When did this member leave?")}
</h3>
<form phx-submit="deactivate" phx-target={@myself}>
<.input
type="date"
id="deactivate-exit-date"
name="exit_date"
label={gettext("Exit date")}
value={@exit_date}
errors={if @error, do: [@error], else: []}
required
phx-mounted={JS.focus()}
/>
<div class="modal-action">
<.button
type="button"
variant="neutral"
phx-click="cancel_modal"
phx-target={@myself}
>
{gettext("Cancel")}
</.button>
<.button type="submit" variant="primary">{gettext("Deactivate")}</.button>
</div>
</form>
</div>
</dialog>
<% end %>
</div>
"""
end
@impl true
def update(assigns, socket) do
{:ok,
socket
|> assign(assigns)
|> assign(:can_update, can?(assigns.current_user, :update, assigns.member))
|> assign_new(:show_modal, fn -> false end)
|> assign_new(:exit_date, fn -> Date.utc_today() end)
|> assign_new(:error, fn -> nil end)}
end
@impl true
def handle_event("open_modal", _params, socket) do
{:noreply,
socket
|> assign(:show_modal, true)
|> assign(:exit_date, Date.utc_today())
|> assign(:error, nil)}
end
def handle_event("cancel_modal", _params, socket) do
{:noreply, close_modal(socket)}
end
def handle_event("dialog_keydown", %{"key" => key}, socket) when key in ["Escape", "Esc"] do
{:noreply, close_modal(socket)}
end
def handle_event("dialog_keydown", _params, socket), do: {:noreply, socket}
def handle_event("deactivate", %{"exit_date" => exit_date_str}, socket) do
case Date.from_iso8601(exit_date_str) do
{:ok, exit_date} ->
apply_exit_date(socket, exit_date, show_inline_error: true)
{:error, _reason} ->
{:noreply, assign(socket, :error, gettext("Invalid date format"))}
end
end
def handle_event("reactivate", _params, socket) do
apply_exit_date(socket, nil, show_inline_error: false)
end
defp apply_exit_date(socket, exit_date, opts) do
member = socket.assigns.member
actor = socket.assigns.current_user
case Membership.update_member(member, %{exit_date: exit_date}, actor: actor) do
{:ok, updated_member} ->
send(self(), {:member_updated, updated_member})
{:noreply,
socket
|> assign(:member, updated_member)
|> close_modal()}
{:error, error} ->
if opts[:show_inline_error] do
{:noreply, assign(socket, :error, format_error(error))}
else
send(self(), {:put_flash, :error, format_error(error)})
{:noreply, socket}
end
end
end
defp close_modal(socket) do
socket
|> assign(:show_modal, false)
|> assign(:error, nil)
end
end

View file

@ -15,6 +15,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
require Ash.Query
import MvWeb.LiveHelpers, only: [current_actor: 1]
import MvWeb.Authorization, only: [can?: 3]
import MvWeb.Helpers.AshErrorHelpers, only: [format_error: 1]
alias Mv.Membership
alias Mv.MembershipFees
@ -143,16 +144,21 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
<%!-- Action Buttons (only when user has permission) --%>
<div class="flex gap-2 mb-4">
<.button
<.tooltip
:if={@member.membership_fee_type != nil and @can_create_cycle}
phx-click="regenerate_cycles"
phx-target={@myself}
class={["btn btn-sm btn-outline", if(@regenerating, do: "btn-disabled", else: "")]}
title={gettext("Generate cycles from the last existing cycle to today")}
content={gettext("Generate cycles from the last existing cycle to today")}
position="top"
>
<.icon name="hero-arrow-path" class="size-4" />
{if(@regenerating, do: gettext("Regenerating..."), else: gettext("Regenerate Cycles"))}
</.button>
<.button
phx-click="regenerate_cycles"
phx-target={@myself}
class={["btn btn-sm btn-outline", if(@regenerating, do: "btn-disabled", else: "")]}
aria-label={gettext("Regenerate membership fee cycles")}
>
<.icon name="hero-arrow-path" class="size-4" />
{if(@regenerating, do: gettext("Regenerating..."), else: gettext("Regenerate Cycles"))}
</.button>
</.tooltip>
<.button
:if={Enum.any?(@cycles) and @can_destroy_cycle}
variant="outline"
@ -1139,17 +1145,6 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
defp format_status_label(:unpaid), do: gettext("Unpaid")
defp format_status_label(:suspended), do: gettext("Suspended")
defp format_error(%Ash.Error.Invalid{} = error) do
Enum.map_join(error.errors, ", ", fn e -> e.message end)
end
defp format_error(%Ash.Error.Forbidden{}) do
gettext("You are not allowed to perform this action.")
end
defp format_error(error) when is_binary(error), do: error
defp format_error(_error), do: gettext("An error occurred")
defp validate_cycle_not_exists(cycles, cycle_start) do
if Enum.any?(cycles, &(&1.cycle_start == cycle_start)) do
{:error, :cycle_exists}

View file

@ -298,17 +298,22 @@ defmodule MvWeb.MembershipFeeSettingsLive do
<.icon name="hero-trash" class="size-4" />
</button>
</.tooltip>
<.button
<.tooltip
:if={get_member_count(mft, @member_counts) == 0}
variant="danger"
size="sm"
phx-click="delete"
phx-value-id={mft.id}
data-confirm={gettext("Are you sure?")}
aria-label={gettext("Delete Membership Fee Type")}
content={gettext("Delete Membership Fee Type")}
position="left"
>
<.icon name="hero-trash" class="size-4" />
</.button>
<.button
variant="danger"
size="sm"
phx-click="delete"
phx-value-id={mft.id}
data-confirm={gettext("Are you sure?")}
aria-label={gettext("Delete Membership Fee Type")}
>
<.icon name="hero-trash" class="size-4" />
</.button>
</.tooltip>
</:action>
</.table>
</div>

View file

@ -115,17 +115,22 @@ defmodule MvWeb.MembershipFeeTypeLive.Index do
<.icon name="hero-trash" class="size-4" />
</button>
</.tooltip>
<.button
<.tooltip
:if={get_member_count(mft, @member_counts) == 0}
variant="danger"
size="sm"
phx-click="delete"
phx-value-id={mft.id}
data-confirm={gettext("Are you sure?")}
aria-label={gettext("Delete Membership Fee Type")}
content={gettext("Delete Membership Fee Type")}
position="left"
>
<.icon name="hero-trash" class="size-4" />
</.button>
<.button
variant="danger"
size="sm"
phx-click="delete"
phx-value-id={mft.id}
data-confirm={gettext("Are you sure?")}
aria-label={gettext("Delete Membership Fee Type")}
>
<.icon name="hero-trash" class="size-4" />
</.button>
</.tooltip>
</:action>
</.table>

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,63 @@
defmodule MvWeb.Helpers.AshErrorHelpersTest do
@moduledoc """
Tests for format_error/1, the shared Ash error formatter used by the
member show LiveComponents.
"""
use Mv.DataCase, async: true
import MvWeb.Helpers.AshErrorHelpers
describe "format_error/1" do
test "joins messages of an Ash.Error.Invalid with commas" do
error = %Ash.Error.Invalid{
errors: [%{message: "exit_date must be after join_date"}, %{message: "another"}]
}
assert format_error(error) == "exit_date must be after join_date, another"
end
test "falls back to inspect for invalid sub-errors without a message" do
error = %Ash.Error.Invalid{errors: [:boom]}
assert format_error(error) == inspect(:boom)
end
test "returns a localized message for an Ash.Error.Forbidden" do
assert format_error(%Ash.Error.Forbidden{}) =~ "allowed"
end
test "passes through a plain binary unchanged" do
assert format_error("custom message") == "custom message"
end
test "returns a generic localized message for anything else" do
assert format_error(:unexpected) == "An error occurred"
end
test "renders the genuine update_member validation error as a user-facing message" do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
{:ok, member} =
Mv.Membership.create_member(
%{
first_name: "Exit",
last_name: "Validation",
email: "exit-validation-#{System.unique_integer([:positive])}@example.com",
join_date: ~D[2024-01-01]
},
actor: system_actor
)
# exit_date earlier than join_date triggers the resource validation
{:error, %Ash.Error.Invalid{} = error} =
Mv.Membership.update_member(member, %{exit_date: ~D[2023-12-31]}, actor: system_actor)
formatted = format_error(error)
# The real Ash sub-error must surface its localized :message, not an inspect()'d struct.
assert formatted == "cannot be before join date"
refute formatted =~ "InvalidAttribute"
refute formatted =~ "%{"
end
end
end

View file

@ -283,4 +283,48 @@ defmodule MvWeb.GlobalSettingsLiveTest do
end
end
end
describe "OIDC section explanation (§1.1)" do
setup %{conn: conn} do
user = create_test_user(%{email: "admin@example.com"})
conn = conn_with_oidc_user(conn, user)
{:ok, conn: conn}
end
test "OIDC section shows a Single Sign-On explanation", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/settings")
assert has_element?(view, "[data-testid='oidc-sso-description']")
assert view |> element("[data-testid='oidc-sso-description']") |> render() =~
"Single Sign-On"
end
end
describe "Vereinfacht sync control tooltip (§1.9)" do
setup %{conn: conn} do
user = create_test_user(%{email: "admin@example.com"})
conn = conn_with_oidc_user(conn, user)
System.put_env("VEREINFACHT_API_URL", "https://example.test/api/v1")
System.put_env("VEREINFACHT_API_KEY", "test-key")
System.put_env("VEREINFACHT_CLUB_ID", "club-1")
on_exit(fn ->
System.delete_env("VEREINFACHT_API_URL")
System.delete_env("VEREINFACHT_API_KEY")
System.delete_env("VEREINFACHT_CLUB_ID")
end)
{:ok, conn: conn}
end
test "global Vereinfacht sync control carries a tooltip and accessible label", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/settings")
# The sync button is wrapped in a <.tooltip> (data-tip) and carries an aria-label
assert has_element?(view, ".tooltip[data-tip] button[phx-click=sync_vereinfacht_contacts]")
assert has_element?(view, "button[phx-click=sync_vereinfacht_contacts][aria-label]")
end
end
end

View file

@ -0,0 +1,108 @@
defmodule MvWeb.MemberLive.DeactivateTest do
@moduledoc """
Tests for the member deactivate/reactivate sub-flow on the member show page,
driven through the parent LiveView (the DeactivateComponent is stateful).
"""
use MvWeb.ConnCase, async: true
import Phoenix.LiveViewTest
use Gettext, backend: MvWeb.Gettext
alias Mv.Fixtures
defp reload_member(member) do
Ash.get!(Mv.Membership.Member, member.id, actor: Mv.Helpers.SystemActor.get_system_actor())
end
describe "deactivate/reactivate control visibility (§1.6, §1.8)" do
@tag role: :admin
test "shows Deactivate and hides Reactivate when member has no exit_date", %{conn: conn} do
member = Fixtures.member_fixture()
{:ok, view, _html} = live(conn, ~p"/members/#{member.id}")
assert has_element?(view, "[data-testid=member-deactivate]")
refute has_element?(view, "[data-testid=member-reactivate]")
end
@tag role: :admin
test "shows Reactivate and hides Deactivate when member has an exit_date", %{conn: conn} do
member = Fixtures.member_fixture(%{exit_date: Date.utc_today()})
{:ok, view, _html} = live(conn, ~p"/members/#{member.id}")
assert has_element?(view, "[data-testid=member-reactivate]")
refute has_element?(view, "[data-testid=member-deactivate]")
end
@tag role: :read_only
test "hides the deactivate/reactivate control for a user without :update permission", %{
conn: conn
} do
member = Fixtures.member_fixture()
{:ok, view, _html} = live(conn, ~p"/members/#{member.id}")
refute has_element?(view, "[data-testid=member-deactivate]")
refute has_element?(view, "[data-testid=member-reactivate]")
end
end
describe "deactivate modal (§1.3)" do
@tag role: :admin
test "opening the deactivate modal prefills the date input with today", %{conn: conn} do
member = Fixtures.member_fixture()
{:ok, view, _html} = live(conn, ~p"/members/#{member.id}")
view
|> element("[data-testid=member-deactivate]")
|> render_click()
assert has_element?(
view,
~s(#deactivate-exit-date[value="#{Date.to_iso8601(Date.utc_today())}"])
)
end
end
describe "submitting the deactivate modal (§1.2)" do
@tag role: :admin
test "submitting the deactivate modal with a future date sets exit_date", %{conn: conn} do
member = Fixtures.member_fixture(%{join_date: Date.utc_today()})
future_date = Date.add(Date.utc_today(), 30)
{:ok, view, _html} = live(conn, ~p"/members/#{member.id}")
view
|> element("[data-testid=member-deactivate]")
|> render_click()
view
|> element("#deactivate-member-modal form")
|> render_submit(%{"exit_date" => Date.to_iso8601(future_date)})
assert reload_member(member).exit_date == future_date
# UI flips to offering Reactivate
assert has_element?(view, "[data-testid=member-reactivate]")
end
end
describe "reactivate (§1.7)" do
@tag role: :admin
test "reactivating a member clears exit_date", %{conn: conn} do
member = Fixtures.member_fixture(%{exit_date: Date.utc_today()})
{:ok, view, _html} = live(conn, ~p"/members/#{member.id}")
view
|> element("[data-testid=member-reactivate]")
|> render_click()
assert reload_member(member).exit_date == nil
# UI flips back to offering Deactivate
assert has_element?(view, "[data-testid=member-deactivate]")
end
end
end

View file

@ -111,6 +111,17 @@ defmodule MvWeb.MemberLive.IndexTest do
end
end
describe "drag-select vs click guard (§3.3)" do
@describetag :ui
test "members table carries the row-selection guard hook", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, ~p"/members")
assert has_element?(view, "[phx-hook=RowSelectionGuard][id=members-table-guard]")
end
end
describe "translations" do
@describetag :ui

View file

@ -55,6 +55,22 @@ defmodule MvWeb.MemberLive.ShowMembershipFeesTest do
|> Ash.create!(actor: system_actor)
end
describe "cycle-regeneration control tooltip (§3.5 icon/tooltip audit)" do
test "the regenerate_cycles control carries a tooltip and accessible label", %{conn: conn} do
fee_type = create_fee_type(%{interval: :yearly})
member = Mv.Fixtures.member_fixture(%{membership_fee_type_id: fee_type.id})
{:ok, view, _html} = live(conn, "/members/#{member.id}")
view
|> element("button[phx-click='switch_tab'][phx-value-tab='membership_fees']")
|> render_click()
assert has_element?(view, ".tooltip[data-tip] button[phx-click=regenerate_cycles]")
assert has_element?(view, "button[phx-click=regenerate_cycles][aria-label]")
end
end
describe "cycles table display" do
test "displays all cycles for member", %{conn: conn} do
fee_type = create_fee_type(%{interval: :yearly})