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
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #527
This commit is contained in:
commit
d6c322fd79
20 changed files with 2049 additions and 1390 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
34
lib/mv_web/helpers/ash_error_helpers.ex
Normal file
34
lib/mv_web/helpers/ash_error_helpers.ex
Normal 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
|
||||
|
|
@ -586,15 +586,25 @@ defmodule MvWeb.GlobalSettingsLive do
|
|||
>
|
||||
{gettext("Test Integration")}
|
||||
</.button>
|
||||
<.button
|
||||
<.tooltip
|
||||
:if={Mv.Config.vereinfacht_configured?()}
|
||||
content={
|
||||
gettext(
|
||||
"Creates a Vereinfacht finance contact for every member that does not have one yet."
|
||||
)
|
||||
}
|
||||
position="top"
|
||||
>
|
||||
<.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.")}
|
||||
|
|
|
|||
|
|
@ -250,6 +250,7 @@ defmodule MvWeb.GroupLive.Show do
|
|||
<% end %>
|
||||
</div>
|
||||
</form>
|
||||
<.tooltip content={gettext("Add members")} position="top">
|
||||
<.button
|
||||
type="button"
|
||||
variant="primary"
|
||||
|
|
@ -261,6 +262,7 @@ defmodule MvWeb.GroupLive.Show do
|
|||
>
|
||||
<.icon name="hero-plus" class="size-5" />
|
||||
</.button>
|
||||
</.tooltip>
|
||||
<.button
|
||||
type="button"
|
||||
variant="neutral"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
191
lib/mv_web/live/member_live/show/deactivate_component.ex
Normal file
191
lib/mv_web/live/member_live/show/deactivate_component.ex
Normal 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
|
||||
|
|
@ -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}
|
||||
content={gettext("Generate cycles from the last existing cycle to today")}
|
||||
position="top"
|
||||
>
|
||||
<.button
|
||||
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")}
|
||||
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}
|
||||
|
|
|
|||
|
|
@ -298,8 +298,12 @@ defmodule MvWeb.MembershipFeeSettingsLive do
|
|||
<.icon name="hero-trash" class="size-4" />
|
||||
</button>
|
||||
</.tooltip>
|
||||
<.button
|
||||
<.tooltip
|
||||
:if={get_member_count(mft, @member_counts) == 0}
|
||||
content={gettext("Delete Membership Fee Type")}
|
||||
position="left"
|
||||
>
|
||||
<.button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
phx-click="delete"
|
||||
|
|
@ -309,6 +313,7 @@ defmodule MvWeb.MembershipFeeSettingsLive do
|
|||
>
|
||||
<.icon name="hero-trash" class="size-4" />
|
||||
</.button>
|
||||
</.tooltip>
|
||||
</:action>
|
||||
</.table>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -115,8 +115,12 @@ defmodule MvWeb.MembershipFeeTypeLive.Index do
|
|||
<.icon name="hero-trash" class="size-4" />
|
||||
</button>
|
||||
</.tooltip>
|
||||
<.button
|
||||
<.tooltip
|
||||
:if={get_member_count(mft, @member_counts) == 0}
|
||||
content={gettext("Delete Membership Fee Type")}
|
||||
position="left"
|
||||
>
|
||||
<.button
|
||||
variant="danger"
|
||||
size="sm"
|
||||
phx-click="delete"
|
||||
|
|
@ -126,6 +130,7 @@ defmodule MvWeb.MembershipFeeTypeLive.Index do
|
|||
>
|
||||
<.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
63
test/mv_web/helpers/ash_error_helpers_test.exs
Normal file
63
test/mv_web/helpers/ash_error_helpers_test.exs
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
108
test/mv_web/live/member_live/deactivate_test.exs
Normal file
108
test/mv_web/live/member_live/deactivate_test.exs
Normal 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
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue