feat(member): collect member-overview bulk actions into a single dropdown
The growing row of bulk-action buttons above the member overview is replaced by one "Aktionen" dropdown holding all four actions (open in email program, copy addresses, export CSV, export PDF). With no selection the actions operate on all — or the currently filtered — members; the email-program action is disabled past a recipient cap, because the browser cannot reliably hand a very long mailto over to the mail client. The trigger shows the active scope as a badge: an emphasized count when members are selected, a muted "alle"/"gefiltert" otherwise.
This commit is contained in:
parent
8e5dd7e4c6
commit
c983c8d5bb
10 changed files with 920 additions and 330 deletions
|
|
@ -40,6 +40,8 @@ defmodule Mv.Constants do
|
|||
|
||||
@max_boolean_filters 50
|
||||
|
||||
@max_mailto_bulk_recipients 50
|
||||
|
||||
@max_uuid_length 36
|
||||
|
||||
@email_validator_checks [:html_input, :pow]
|
||||
|
|
@ -173,6 +175,21 @@ defmodule Mv.Constants do
|
|||
"""
|
||||
def max_boolean_filters, do: @max_boolean_filters
|
||||
|
||||
@doc """
|
||||
Returns the maximum number of mailto recipients before the bulk "open in email
|
||||
program" action is disabled.
|
||||
|
||||
The mailto link carries every recipient in its BCC; browsers cannot reliably
|
||||
hand a too-long mailto URI to the mail program. At or above this count the
|
||||
action is disabled in the UI (Copy and Export have no such limit).
|
||||
|
||||
## Examples
|
||||
|
||||
iex> Mv.Constants.max_mailto_bulk_recipients()
|
||||
50
|
||||
"""
|
||||
def max_mailto_bulk_recipients, do: @max_mailto_bulk_recipients
|
||||
|
||||
@doc """
|
||||
Returns the maximum length of a UUID string (36 characters including hyphens).
|
||||
|
||||
|
|
|
|||
243
lib/mv_web/components/bulk_actions_dropdown.ex
Normal file
243
lib/mv_web/components/bulk_actions_dropdown.ex
Normal file
|
|
@ -0,0 +1,243 @@
|
|||
defmodule MvWeb.Components.BulkActionsDropdown do
|
||||
@moduledoc """
|
||||
Single "Aktionen" dropdown bundling the four member bulk actions, flattened to
|
||||
one level: open in email program (mailto), copy email addresses, export to CSV,
|
||||
export to PDF.
|
||||
|
||||
It keeps the CSRF-protected `<form>` POST export items unchanged (CSV/PDF) and
|
||||
adds the mailto and copy items that previously lived as standalone header
|
||||
buttons next to a separate export dropdown.
|
||||
|
||||
## Scope and trigger badge
|
||||
|
||||
The trigger reads `Aktionen` followed by a scope badge: an emphasized
|
||||
(`primary`) count `N` when `N` members are selected, and a muted (`neutral`)
|
||||
badge otherwise — `gefiltert` when a search term or filter narrows the list,
|
||||
`alle` when nothing is selected and no search/filter is active. Only an actual
|
||||
selection is emphasized. The badge sits inside the shared `dropdown_menu/1`
|
||||
trigger via its `trigger_badge` slot, matching the member-filter dropdown's
|
||||
count badge. The `scope`, `selected_count`, `mailto_bcc`, `recipient_count`
|
||||
and `mailto_disabled?` are computed by the parent LiveView and passed in.
|
||||
|
||||
## Recipient handling (mailto / copy)
|
||||
|
||||
The parent already excludes members without an email when building
|
||||
`mailto_bcc` and `recipient_count` (defensive filter preserved verbatim from
|
||||
the previous behaviour). Export, by contrast, still includes every member in
|
||||
scope regardless of email — its payload is unchanged.
|
||||
|
||||
## Mailto recipient cap
|
||||
|
||||
A mailto URI carries every recipient in its BCC; browsers cannot reliably hand
|
||||
a very long mailto over to the mail program. When `mailto_disabled?` is true
|
||||
(recipient count at or above `Mv.Constants.max_mailto_bulk_recipients/0`) the
|
||||
mailto item is rendered disabled (`aria-disabled`, `tabindex="-1"`, href
|
||||
dropped) with an explanatory tooltip. Copy and Export have no such cap.
|
||||
|
||||
## Event routing
|
||||
|
||||
`dropdown_menu/1` sends `toggle_dropdown`/`close_dropdown` to `@myself`, so the
|
||||
component owns its own `:open` state. The copy item carries an *un-targeted*
|
||||
`phx-click="copy_emails"`, which therefore reaches the parent LiveView's
|
||||
`handle_event/3` (which keeps access to `@members`), plus the
|
||||
`CopyToClipboard` hook.
|
||||
"""
|
||||
use MvWeb, :live_component
|
||||
use Gettext, backend: MvWeb.Gettext
|
||||
|
||||
# Same focus ring as CoreComponents button/dropdown (WCAG 2.4.7)
|
||||
defp dropdown_item_class do
|
||||
focus =
|
||||
MvWeb.CoreComponents.button_focus_classes()
|
||||
|> Kernel.++(["focus-visible:ring-inset"])
|
||||
|> Enum.join(" ")
|
||||
|
||||
"flex items-center gap-2 px-2 py-1 rounded cursor-pointer hover:bg-base-200 w-full text-left whitespace-nowrap #{focus}"
|
||||
end
|
||||
|
||||
@impl true
|
||||
def mount(socket) do
|
||||
{:ok, assign(socket, :open, false)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def update(assigns, socket) do
|
||||
socket =
|
||||
socket
|
||||
|> assign(:id, assigns.id)
|
||||
|> assign(:export_payload_json, assigns[:export_payload_json] || "")
|
||||
|> assign(:selected_count, assigns[:selected_count] || 0)
|
||||
|> assign(:scope, assigns[:scope] || :all)
|
||||
|> assign(:mailto_bcc, assigns[:mailto_bcc] || "")
|
||||
|> assign(:recipient_count, assigns[:recipient_count] || 0)
|
||||
|> assign(:mailto_disabled?, assigns[:mailto_disabled?] || false)
|
||||
|
||||
# The parent never sets :open (the component owns it via toggle/close).
|
||||
# Honouring an explicit :open assign keeps the component renderable in
|
||||
# isolation (render_component/2) for structural tests.
|
||||
socket =
|
||||
case Map.fetch(assigns, :open) do
|
||||
{:ok, open} -> assign(socket, :open, open)
|
||||
:error -> socket
|
||||
end
|
||||
|
||||
{:ok, socket}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
assigns =
|
||||
assigns
|
||||
|> assign(:scope_label, scope_label(assigns))
|
||||
|> assign(:scope_variant, scope_variant(assigns))
|
||||
|
||||
~H"""
|
||||
<div id={@id} data-testid="bulk-actions-dropdown" class="flex-auto flex-wrap">
|
||||
<.dropdown_menu
|
||||
id={"#{@id}-menu"}
|
||||
button_label={gettext("Actions")}
|
||||
icon="hero-bolt"
|
||||
open={@open}
|
||||
phx_target={@myself}
|
||||
menu_width="w-64"
|
||||
menu_align="left"
|
||||
button_class="btn-secondary gap-2"
|
||||
testid="bulk-actions-dropdown"
|
||||
button_testid="bulk-actions-button"
|
||||
menu_testid="bulk-actions-menu"
|
||||
>
|
||||
<:trigger_badge>
|
||||
<.badge variant={@scope_variant} size="sm" data-testid="bulk-actions-scope-badge">
|
||||
{@scope_label}
|
||||
</.badge>
|
||||
</:trigger_badge>
|
||||
<li role="none">
|
||||
<.mailto_item mailto_bcc={@mailto_bcc} disabled={@mailto_disabled?} />
|
||||
</li>
|
||||
<li role="none">
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
id="bulk-actions-copy"
|
||||
phx-hook="CopyToClipboard"
|
||||
phx-click="copy_emails"
|
||||
class={dropdown_item_class()}
|
||||
aria-label={gettext("Copy email addresses")}
|
||||
data-testid="bulk-actions-copy"
|
||||
>
|
||||
<.icon name="hero-clipboard-document" class="h-4 w-4" />
|
||||
<span>{gettext("Copy email addresses")}</span>
|
||||
</button>
|
||||
</li>
|
||||
<li role="none">
|
||||
<form method="post" action={~p"/members/export.csv"} target="_blank" class="w-full">
|
||||
<input type="hidden" name="_csrf_token" value={Plug.CSRFProtection.get_csrf_token()} />
|
||||
<input type="hidden" name="payload" value={@export_payload_json} />
|
||||
<button
|
||||
type="submit"
|
||||
role="menuitem"
|
||||
class={dropdown_item_class()}
|
||||
aria-label={gettext("Export members to CSV")}
|
||||
data-testid="export-csv-link"
|
||||
>
|
||||
<.icon name="hero-document-arrow-down" class="h-4 w-4" />
|
||||
<span>{gettext("Export to CSV")}</span>
|
||||
</button>
|
||||
</form>
|
||||
</li>
|
||||
<li role="none">
|
||||
<form method="post" action={~p"/members/export.pdf"} target="_blank" class="w-full">
|
||||
<input type="hidden" name="_csrf_token" value={Plug.CSRFProtection.get_csrf_token()} />
|
||||
<input type="hidden" name="payload" value={@export_payload_json} />
|
||||
<button
|
||||
type="submit"
|
||||
role="menuitem"
|
||||
class={dropdown_item_class()}
|
||||
aria-label={gettext("Export members to PDF")}
|
||||
data-testid="export-pdf-link"
|
||||
>
|
||||
<.icon name="hero-document-text" class="h-4 w-4" />
|
||||
<span>{gettext("Export to PDF")}</span>
|
||||
</button>
|
||||
</form>
|
||||
</li>
|
||||
</.dropdown_menu>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
# The mailto item is an anchor menu item. When over the recipient cap it is
|
||||
# rendered disabled following the same a11y pattern as a disabled CoreComponents
|
||||
# link button (href dropped, tabindex=-1, aria-disabled=true) and exposes the
|
||||
# explanatory tooltip via title.
|
||||
attr :mailto_bcc, :string, required: true
|
||||
attr :disabled, :boolean, required: true
|
||||
|
||||
defp mailto_item(%{disabled: true} = assigns) do
|
||||
assigns = assign(assigns, :item_class, dropdown_item_class())
|
||||
|
||||
~H"""
|
||||
<a
|
||||
role="menuitem"
|
||||
tabindex="-1"
|
||||
aria-disabled="true"
|
||||
title={over_threshold_tooltip()}
|
||||
class={[@item_class, "opacity-50 pointer-events-none"]}
|
||||
aria-label={gettext("Open in email program")}
|
||||
data-testid="bulk-actions-mailto"
|
||||
>
|
||||
<.icon name="hero-envelope" class="h-4 w-4" />
|
||||
<span>{gettext("Open in email program")}</span>
|
||||
</a>
|
||||
"""
|
||||
end
|
||||
|
||||
defp mailto_item(%{disabled: false} = assigns) do
|
||||
assigns = assign(assigns, :item_class, dropdown_item_class())
|
||||
|
||||
~H"""
|
||||
<a
|
||||
role="menuitem"
|
||||
tabindex="0"
|
||||
href={"mailto:?bcc=" <> @mailto_bcc}
|
||||
class={@item_class}
|
||||
aria-label={gettext("Open in email program")}
|
||||
data-testid="bulk-actions-mailto"
|
||||
>
|
||||
<.icon name="hero-envelope" class="h-4 w-4" />
|
||||
<span>{gettext("Open in email program")}</span>
|
||||
</a>
|
||||
"""
|
||||
end
|
||||
|
||||
defp over_threshold_tooltip do
|
||||
gettext("Too many recipients for this function. Copy the addresses or export the list.")
|
||||
end
|
||||
|
||||
# The trigger scope is shown as a badge after the "Aktionen" label. Only an
|
||||
# actual selection is emphasized (primary); both the "filtered" and "all"
|
||||
# scopes are muted (neutral), since neither means members are selected.
|
||||
defp scope_label(assigns) do
|
||||
case assigns.scope do
|
||||
:selection -> to_string(assigns.selected_count)
|
||||
:filtered -> gettext("filtered")
|
||||
_ -> gettext("all")
|
||||
end
|
||||
end
|
||||
|
||||
defp scope_variant(assigns) do
|
||||
case assigns.scope do
|
||||
:selection -> "primary"
|
||||
_ -> "neutral"
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("toggle_dropdown", _params, socket) do
|
||||
{:noreply, assign(socket, :open, !socket.assigns.open)}
|
||||
end
|
||||
|
||||
def handle_event("close_dropdown", _params, socket) do
|
||||
{:noreply, assign(socket, :open, false)}
|
||||
end
|
||||
end
|
||||
|
|
@ -1,110 +0,0 @@
|
|||
defmodule MvWeb.Components.ExportDropdown do
|
||||
@moduledoc """
|
||||
Export dropdown component for member export (CSV/PDF).
|
||||
|
||||
Provides an accessible dropdown menu with CSV and PDF export options.
|
||||
Uses the same export payload as the previous single-button export.
|
||||
"""
|
||||
use MvWeb, :live_component
|
||||
use Gettext, backend: MvWeb.Gettext
|
||||
|
||||
# Same focus ring as CoreComponents button/dropdown (WCAG 2.4.7)
|
||||
defp dropdown_item_class do
|
||||
focus =
|
||||
MvWeb.CoreComponents.button_focus_classes()
|
||||
|> Kernel.++(["focus-visible:ring-inset"])
|
||||
|> Enum.join(" ")
|
||||
|
||||
"flex items-center gap-2 px-2 py-1 rounded cursor-pointer hover:bg-base-200 w-full text-left #{focus}"
|
||||
end
|
||||
|
||||
@impl true
|
||||
def mount(socket) do
|
||||
{:ok, assign(socket, :open, false)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def update(assigns, socket) do
|
||||
socket =
|
||||
socket
|
||||
|> assign(:id, assigns.id)
|
||||
|> assign(:export_payload_json, assigns[:export_payload_json] || "")
|
||||
|> assign(:selected_count, assigns[:selected_count] || 0)
|
||||
|
||||
{:ok, socket}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
button_label =
|
||||
gettext("Export") <>
|
||||
" (" <>
|
||||
if(assigns.selected_count == 0,
|
||||
do: gettext("all"),
|
||||
else: to_string(assigns.selected_count)
|
||||
) <>
|
||||
")"
|
||||
|
||||
assigns = assign(assigns, :button_label, button_label)
|
||||
|
||||
~H"""
|
||||
<div id={@id} data-testid="export-dropdown" class="flex-auto flex-wrap">
|
||||
<.dropdown_menu
|
||||
id={"#{@id}-menu"}
|
||||
button_label={@button_label}
|
||||
icon="hero-arrow-down-tray"
|
||||
open={@open}
|
||||
phx_target={@myself}
|
||||
menu_width="w-48"
|
||||
menu_align="left"
|
||||
button_class="btn-secondary gap-2"
|
||||
testid="export-dropdown"
|
||||
button_testid="export-dropdown-button"
|
||||
menu_testid="export-dropdown-menu"
|
||||
>
|
||||
<li role="none">
|
||||
<form method="post" action={~p"/members/export.csv"} target="_blank" class="w-full">
|
||||
<input type="hidden" name="_csrf_token" value={Plug.CSRFProtection.get_csrf_token()} />
|
||||
<input type="hidden" name="payload" value={@export_payload_json} />
|
||||
<button
|
||||
type="submit"
|
||||
role="menuitem"
|
||||
class={dropdown_item_class()}
|
||||
aria-label={gettext("Export members to CSV")}
|
||||
data-testid="export-csv-link"
|
||||
>
|
||||
<.icon name="hero-document-arrow-down" class="h-4 w-4" />
|
||||
<span>{gettext("CSV")}</span>
|
||||
</button>
|
||||
</form>
|
||||
</li>
|
||||
<li role="none">
|
||||
<form method="post" action={~p"/members/export.pdf"} target="_blank" class="w-full">
|
||||
<input type="hidden" name="_csrf_token" value={Plug.CSRFProtection.get_csrf_token()} />
|
||||
<input type="hidden" name="payload" value={@export_payload_json} />
|
||||
<button
|
||||
type="submit"
|
||||
role="menuitem"
|
||||
class={dropdown_item_class()}
|
||||
aria-label={gettext("Export members to PDF")}
|
||||
data-testid="export-pdf-link"
|
||||
>
|
||||
<.icon name="hero-document-text" class="h-4 w-4" />
|
||||
<span>{gettext("PDF")}</span>
|
||||
</button>
|
||||
</form>
|
||||
</li>
|
||||
</.dropdown_menu>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("toggle_dropdown", _params, socket) do
|
||||
{:noreply, assign(socket, :open, !socket.assigns.open)}
|
||||
end
|
||||
|
||||
def handle_event("close_dropdown", _params, socket) do
|
||||
{:noreply, assign(socket, :open, false)}
|
||||
end
|
||||
end
|
||||
|
|
@ -17,7 +17,7 @@ defmodule MvWeb.MemberLive.Index do
|
|||
## Events
|
||||
- `select_member` - Toggle individual member selection
|
||||
- `select_all` - Toggle selection of all visible members
|
||||
- `copy_emails` - Copy email addresses of selected members to clipboard
|
||||
- `copy_emails` - Copy email addresses of the selected members, or of all/filtered members when nothing is selected
|
||||
|
||||
## Implementation Notes
|
||||
- Search uses PostgreSQL full-text search (plainto_tsquery)
|
||||
|
|
@ -250,20 +250,21 @@ defmodule MvWeb.MemberLive.Index do
|
|||
|
||||
@impl true
|
||||
def handle_event("copy_emails", _params, socket) do
|
||||
members = socket.assigns.members
|
||||
selected_ids = socket.assigns.selected_members
|
||||
any_selected? = Enum.any?(members, &MapSet.member?(selected_ids, &1.id))
|
||||
|
||||
# Filter members that are in the selection and have email addresses
|
||||
formatted_emails = format_selected_member_emails(socket.assigns.members, selected_ids)
|
||||
# Recipients follow the current scope: the selection when present, otherwise
|
||||
# every member in the (filtered) list. Members without an email are excluded
|
||||
# in both cases (unchanged missing-email handling). With no selection we no
|
||||
# longer hard-stop with "No members selected" — we act on the scope; the
|
||||
# empty-recipient feedback below is preserved.
|
||||
formatted_emails = scope_member_emails(members, selected_ids, any_selected?)
|
||||
email_count = length(formatted_emails)
|
||||
|
||||
cond do
|
||||
MapSet.size(selected_ids) == 0 ->
|
||||
{:noreply, put_flash(socket, :error, gettext("No members selected"))}
|
||||
|
||||
email_count == 0 ->
|
||||
if email_count == 0 do
|
||||
{:noreply, put_flash(socket, :error, gettext("No email addresses found"))}
|
||||
|
||||
true ->
|
||||
else
|
||||
# RFC 5322 uses comma as separator for email address lists
|
||||
email_string = Enum.join(formatted_emails, ", ")
|
||||
|
||||
|
|
@ -1812,24 +1813,79 @@ defmodule MvWeb.MemberLive.Index do
|
|||
selected_count = Enum.count(members, &MapSet.member?(selected_members, &1.id))
|
||||
any_selected? = Enum.any?(members, &MapSet.member?(selected_members, &1.id))
|
||||
|
||||
# Scope drives the trigger label: the selection when present, otherwise the
|
||||
# whole list (filtered, when a search term or any filter is active).
|
||||
scope =
|
||||
cond do
|
||||
any_selected? -> :selection
|
||||
filters_active?(socket.assigns) -> :filtered
|
||||
true -> :all
|
||||
end
|
||||
|
||||
# Copy/Mailto recipients: the members in scope that have a usable email.
|
||||
# With a selection that is the selected subset (existing behaviour); without
|
||||
# a selection it is every member in scope (deliberate behaviour change). In
|
||||
# both cases members without an email are excluded, exactly as today's
|
||||
# format_selected_member_emails does for the selection case.
|
||||
recipient_emails = scope_member_emails(members, selected_members, any_selected?)
|
||||
recipient_count = length(recipient_emails)
|
||||
|
||||
# RFC 6068: mailto URI params must use %20 for spaces, not + (encode_www_form uses +)
|
||||
mailto_bcc =
|
||||
if any_selected? do
|
||||
format_selected_member_emails(members, selected_members)
|
||||
recipient_emails
|
||||
|> Enum.join(", ")
|
||||
|> URI.encode_www_form()
|
||||
|> String.replace("+", "%20")
|
||||
else
|
||||
""
|
||||
end
|
||||
|
||||
mailto_disabled? = recipient_count >= Mv.Constants.max_mailto_bulk_recipients()
|
||||
|
||||
socket
|
||||
|> assign(:selected_count, selected_count)
|
||||
|> assign(:any_selected?, any_selected?)
|
||||
|> assign(:scope, scope)
|
||||
|> assign(:recipient_count, recipient_count)
|
||||
|> assign(:mailto_disabled?, mailto_disabled?)
|
||||
|> assign(:mailto_bcc, mailto_bcc)
|
||||
|> assign_export_payload()
|
||||
end
|
||||
|
||||
# Returns the formatted "Name <email>" recipient list for the current scope:
|
||||
# the selected members when any are selected, otherwise every member in the
|
||||
# (filtered) list. Members without an email are excluded in both cases.
|
||||
defp scope_member_emails(members, selected_members, true = _any_selected?),
|
||||
do: format_selected_member_emails(members, selected_members)
|
||||
|
||||
defp scope_member_emails(members, _selected_members, false = _any_selected?) do
|
||||
members
|
||||
|> Enum.filter(fn member -> member.email && member.email != "" end)
|
||||
|> Enum.map(&format_member_email/1)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns true when the member list is restricted by a non-empty search term or
|
||||
any active filter (cycle status, group, fee type, boolean custom field, or a
|
||||
date filter differing from the default). Drives the "(gefiltert)" vs "(alle)"
|
||||
trigger label and reads only assigns — no DB access.
|
||||
"""
|
||||
def filters_active?(assigns) do
|
||||
search_active?(assigns) or selection_filters_active?(assigns) or date_filter_active?(assigns)
|
||||
end
|
||||
|
||||
defp search_active?(assigns) do
|
||||
query = assigns[:query]
|
||||
is_binary(query) and query != ""
|
||||
end
|
||||
|
||||
defp selection_filters_active?(assigns) do
|
||||
not is_nil(assigns[:cycle_status_filter]) or
|
||||
map_size(assigns[:group_filters] || %{}) > 0 or
|
||||
map_size(assigns[:fee_type_filters] || %{}) > 0 or
|
||||
map_size(assigns[:boolean_custom_field_filters] || %{}) > 0
|
||||
end
|
||||
|
||||
defp date_filter_active?(assigns) do
|
||||
(assigns[:date_filters] || DateFilter.default()) != DateFilter.default()
|
||||
end
|
||||
|
||||
defp assign_export_payload(socket) do
|
||||
payload = build_export_payload(socket)
|
||||
assign(socket, :export_payload_json, Jason.encode!(payload))
|
||||
|
|
|
|||
|
|
@ -3,32 +3,15 @@
|
|||
{@content_title}
|
||||
<:actions>
|
||||
<.live_component
|
||||
module={MvWeb.Components.ExportDropdown}
|
||||
id="export-dropdown"
|
||||
module={MvWeb.Components.BulkActionsDropdown}
|
||||
id="bulk-actions-dropdown"
|
||||
export_payload_json={@export_payload_json}
|
||||
selected_count={@selected_count}
|
||||
scope={@scope}
|
||||
mailto_bcc={@mailto_bcc}
|
||||
recipient_count={@recipient_count}
|
||||
mailto_disabled?={@mailto_disabled?}
|
||||
/>
|
||||
<.button
|
||||
variant="secondary"
|
||||
id="copy-emails-btn"
|
||||
phx-hook="CopyToClipboard"
|
||||
phx-click="copy_emails"
|
||||
disabled={not @any_selected?}
|
||||
aria-label={gettext("Copy email addresses of selected members")}
|
||||
>
|
||||
<.icon name="hero-clipboard-document" />
|
||||
{gettext("Copy email addresses")} ({@selected_count})
|
||||
</.button>
|
||||
<.button
|
||||
variant="secondary"
|
||||
id="open-email-btn"
|
||||
href={"mailto:?bcc=" <> @mailto_bcc}
|
||||
disabled={not @any_selected?}
|
||||
aria-label={gettext("Open email program with BCC recipients")}
|
||||
>
|
||||
<.icon name="hero-envelope" />
|
||||
{gettext("Open in email program")}
|
||||
</.button>
|
||||
<%= if can?(@current_user, :create, Mv.Membership.Member) do %>
|
||||
<.button variant="primary" navigate={~p"/members/new"} data-testid="member-new">
|
||||
<.icon name="hero-plus" /> {gettext("New Member")}
|
||||
|
|
|
|||
|
|
@ -84,6 +84,7 @@ msgstr "Über Mitgliedsbeitragsarten"
|
|||
msgid "Accounting-Software (Vereinfacht) Integration"
|
||||
msgstr "Buchhaltungs-Software (Vereinfacht) Integration"
|
||||
|
||||
#: lib/mv_web/components/bulk_actions_dropdown.ex
|
||||
#: lib/mv_web/components/core_components.ex
|
||||
#: lib/mv_web/live/group_live/show.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
|
|
@ -366,11 +367,6 @@ msgstr "Mitglied werden"
|
|||
msgid "By submitting your application you will receive an email with a confirmation link. Once you have confirmed your email address, your application will be reviewed."
|
||||
msgstr "Mit Absenden deines Antrags erhältst du eine Mail mit einem Bestätigungslink. Sobald du deine Mail-Adresse bestätigt hast, wird dein Antrag geprüft."
|
||||
|
||||
#: lib/mv_web/components/export_dropdown.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "CSV"
|
||||
msgstr "CSV"
|
||||
|
||||
#: lib/mv_web/live/import_live/components.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "CSV File"
|
||||
|
|
@ -669,16 +665,11 @@ msgid_plural "Copied %{count} email addresses to clipboard"
|
|||
msgstr[0] "%{count} E-Mail-Adresse in die Zwischenablage kopiert"
|
||||
msgstr[1] "%{count} E-Mail-Adressen in die Zwischenablage kopiert"
|
||||
|
||||
#: lib/mv_web/live/member_live/index.html.heex
|
||||
#: lib/mv_web/components/bulk_actions_dropdown.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Copy email addresses"
|
||||
msgstr "E-Mail-Adressen kopieren"
|
||||
|
||||
#: lib/mv_web/live/member_live/index.html.heex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Copy email addresses of selected members"
|
||||
msgstr "E-Mail-Adressen der ausgewählten Mitglieder kopieren"
|
||||
|
||||
#: lib/mv_web/live/datafields_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Could not load data fields. Please check your permissions."
|
||||
|
|
@ -1197,22 +1188,17 @@ msgstr "Austritte"
|
|||
msgid "Expense"
|
||||
msgstr "Ausgabe"
|
||||
|
||||
#: lib/mv_web/components/export_dropdown.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Export"
|
||||
msgstr "Export"
|
||||
|
||||
#: lib/mv_web/controllers/member_pdf_export_controller.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Export contains %{count} rows, maximum is %{max}"
|
||||
msgstr "Export enthält %{count} Zeilen, Maximum ist %{max}."
|
||||
|
||||
#: lib/mv_web/components/export_dropdown.ex
|
||||
#: lib/mv_web/components/bulk_actions_dropdown.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Export members to CSV"
|
||||
msgstr "Mitglieder als CSV exportieren"
|
||||
|
||||
#: lib/mv_web/components/export_dropdown.ex
|
||||
#: lib/mv_web/components/bulk_actions_dropdown.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Export members to PDF"
|
||||
msgstr "Mitglieder als PDF exportieren"
|
||||
|
|
@ -2203,11 +2189,6 @@ msgstr "Kein Mitglied verknüpft"
|
|||
msgid "No members in this group"
|
||||
msgstr "Keine Mitglieder in dieser Gruppe"
|
||||
|
||||
#: lib/mv_web/live/member_live/index.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "No members selected"
|
||||
msgstr "Keine Mitglieder ausgewählt"
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "No membership fee cycles found. Cycles will be generated automatically when a membership fee type is assigned."
|
||||
|
|
@ -2361,12 +2342,7 @@ msgstr "Nur Administrator*innen oder die verknüpfte*n Benutzer*in(nen) können
|
|||
msgid "Only possible if no members are assigned to this type."
|
||||
msgstr "Nur möglich, wenn diesem Typ keine Mitglieder zugewiesen sind."
|
||||
|
||||
#: lib/mv_web/live/member_live/index.html.heex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Open email program with BCC recipients"
|
||||
msgstr "E-Mail-Programm mit BCC-Empfänger*innen öffnen"
|
||||
|
||||
#: lib/mv_web/live/member_live/index.html.heex
|
||||
#: lib/mv_web/components/bulk_actions_dropdown.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Open in email program"
|
||||
msgstr "Im E-Mail-Programm öffnen"
|
||||
|
|
@ -2392,11 +2368,6 @@ msgstr "Optional"
|
|||
msgid "Options"
|
||||
msgstr "Optionen"
|
||||
|
||||
#: lib/mv_web/components/export_dropdown.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "PDF"
|
||||
msgstr "PDF"
|
||||
|
||||
#: lib/mv/membership/members_pdf.ex
|
||||
#: lib/mv_web/live/components/member_filter_component.ex
|
||||
#: lib/mv_web/live/member_live/show.ex
|
||||
|
|
@ -3575,7 +3546,7 @@ msgstr "Dein Passwort wurde erfolgreich zurückgesetzt"
|
|||
msgid "admin - Unrestricted access"
|
||||
msgstr "admin – Uneingeschränkter Zugriff"
|
||||
|
||||
#: lib/mv_web/components/export_dropdown.ex
|
||||
#: lib/mv_web/components/bulk_actions_dropdown.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "all"
|
||||
msgstr "alle"
|
||||
|
|
@ -3968,7 +3939,22 @@ msgstr "Zeitraum"
|
|||
msgid "To"
|
||||
msgstr "Bis"
|
||||
|
||||
#~ #: lib/mv_web/live/group_live/show.ex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "No members selected."
|
||||
#~ msgstr "Keine Mitglieder ausgewählt."
|
||||
#: lib/mv_web/components/bulk_actions_dropdown.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Export to CSV"
|
||||
msgstr "Als CSV exportieren"
|
||||
|
||||
#: lib/mv_web/components/bulk_actions_dropdown.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Export to PDF"
|
||||
msgstr "Als PDF exportieren"
|
||||
|
||||
#: lib/mv_web/components/bulk_actions_dropdown.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Too many recipients for this function. Copy the addresses or export the list."
|
||||
msgstr "Zu viele Empfänger für diese Funktion. Kopiere die Adressen oder exportiere die Liste."
|
||||
|
||||
#: lib/mv_web/components/bulk_actions_dropdown.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "filtered"
|
||||
msgstr "gefiltert"
|
||||
|
|
|
|||
|
|
@ -85,6 +85,7 @@ msgstr ""
|
|||
msgid "Accounting-Software (Vereinfacht) Integration"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/bulk_actions_dropdown.ex
|
||||
#: lib/mv_web/components/core_components.ex
|
||||
#: lib/mv_web/live/group_live/show.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
|
|
@ -367,11 +368,6 @@ msgstr ""
|
|||
msgid "By submitting your application you will receive an email with a confirmation link. Once you have confirmed your email address, your application will be reviewed."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/export_dropdown.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "CSV"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/import_live/components.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "CSV File"
|
||||
|
|
@ -670,16 +666,11 @@ msgid_plural "Copied %{count} email addresses to clipboard"
|
|||
msgstr[0] ""
|
||||
msgstr[1] ""
|
||||
|
||||
#: lib/mv_web/live/member_live/index.html.heex
|
||||
#: lib/mv_web/components/bulk_actions_dropdown.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Copy email addresses"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/index.html.heex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Copy email addresses of selected members"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/datafields_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Could not load data fields. Please check your permissions."
|
||||
|
|
@ -1198,22 +1189,17 @@ msgstr ""
|
|||
msgid "Expense"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/export_dropdown.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Export"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/controllers/member_pdf_export_controller.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Export contains %{count} rows, maximum is %{max}"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/export_dropdown.ex
|
||||
#: lib/mv_web/components/bulk_actions_dropdown.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Export members to CSV"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/export_dropdown.ex
|
||||
#: lib/mv_web/components/bulk_actions_dropdown.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Export members to PDF"
|
||||
msgstr ""
|
||||
|
|
@ -2204,11 +2190,6 @@ msgstr ""
|
|||
msgid "No members in this group"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/index.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "No members selected"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "No membership fee cycles found. Cycles will be generated automatically when a membership fee type is assigned."
|
||||
|
|
@ -2362,12 +2343,7 @@ msgstr ""
|
|||
msgid "Only possible if no members are assigned to this type."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/index.html.heex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Open email program with BCC recipients"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/index.html.heex
|
||||
#: lib/mv_web/components/bulk_actions_dropdown.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Open in email program"
|
||||
msgstr ""
|
||||
|
|
@ -2393,11 +2369,6 @@ msgstr ""
|
|||
msgid "Options"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/export_dropdown.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "PDF"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv/membership/members_pdf.ex
|
||||
#: lib/mv_web/live/components/member_filter_component.ex
|
||||
#: lib/mv_web/live/member_live/show.ex
|
||||
|
|
@ -3575,7 +3546,7 @@ msgstr ""
|
|||
msgid "admin - Unrestricted access"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/export_dropdown.ex
|
||||
#: lib/mv_web/components/bulk_actions_dropdown.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "all"
|
||||
msgstr ""
|
||||
|
|
@ -3967,3 +3938,23 @@ msgstr ""
|
|||
#, elixir-autogen, elixir-format
|
||||
msgid "To"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/bulk_actions_dropdown.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Export to CSV"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/bulk_actions_dropdown.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Export to PDF"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/bulk_actions_dropdown.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Too many recipients for this function. Copy the addresses or export the list."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/bulk_actions_dropdown.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "filtered"
|
||||
msgstr ""
|
||||
|
|
|
|||
|
|
@ -85,6 +85,7 @@ msgstr ""
|
|||
msgid "Accounting-Software (Vereinfacht) Integration"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/bulk_actions_dropdown.ex
|
||||
#: lib/mv_web/components/core_components.ex
|
||||
#: lib/mv_web/live/group_live/show.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
|
|
@ -367,11 +368,6 @@ msgstr ""
|
|||
msgid "By submitting your application you will receive an email with a confirmation link. Once you have confirmed your email address, your application will be reviewed."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/export_dropdown.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "CSV"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/import_live/components.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "CSV File"
|
||||
|
|
@ -670,16 +666,11 @@ msgid_plural "Copied %{count} email addresses to clipboard"
|
|||
msgstr[0] ""
|
||||
msgstr[1] ""
|
||||
|
||||
#: lib/mv_web/live/member_live/index.html.heex
|
||||
#: lib/mv_web/components/bulk_actions_dropdown.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Copy email addresses"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/index.html.heex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Copy email addresses of selected members"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/datafields_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Could not load data fields. Please check your permissions."
|
||||
|
|
@ -1198,22 +1189,17 @@ msgstr ""
|
|||
msgid "Expense"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/export_dropdown.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Export"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/controllers/member_pdf_export_controller.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Export contains %{count} rows, maximum is %{max}"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/export_dropdown.ex
|
||||
#: lib/mv_web/components/bulk_actions_dropdown.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Export members to CSV"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/export_dropdown.ex
|
||||
#: lib/mv_web/components/bulk_actions_dropdown.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Export members to PDF"
|
||||
msgstr ""
|
||||
|
|
@ -2204,11 +2190,6 @@ msgstr ""
|
|||
msgid "No members in this group"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/index.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "No members selected"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "No membership fee cycles found. Cycles will be generated automatically when a membership fee type is assigned."
|
||||
|
|
@ -2362,12 +2343,7 @@ msgstr ""
|
|||
msgid "Only possible if no members are assigned to this type."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/index.html.heex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Open email program with BCC recipients"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/index.html.heex
|
||||
#: lib/mv_web/components/bulk_actions_dropdown.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Open in email program"
|
||||
msgstr ""
|
||||
|
|
@ -2393,11 +2369,6 @@ msgstr ""
|
|||
msgid "Options"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/export_dropdown.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "PDF"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv/membership/members_pdf.ex
|
||||
#: lib/mv_web/live/components/member_filter_component.ex
|
||||
#: lib/mv_web/live/member_live/show.ex
|
||||
|
|
@ -3575,7 +3546,7 @@ msgstr ""
|
|||
msgid "admin - Unrestricted access"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/export_dropdown.ex
|
||||
#: lib/mv_web/components/bulk_actions_dropdown.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "all"
|
||||
msgstr ""
|
||||
|
|
@ -3968,7 +3939,22 @@ msgstr ""
|
|||
msgid "To"
|
||||
msgstr ""
|
||||
|
||||
#~ #: lib/mv_web/live/group_live/show.ex
|
||||
#~ #, elixir-autogen, elixir-format, fuzzy
|
||||
#~ msgid "No members selected."
|
||||
#~ msgstr ""
|
||||
#: lib/mv_web/components/bulk_actions_dropdown.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Export to CSV"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/bulk_actions_dropdown.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Export to PDF"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/bulk_actions_dropdown.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Too many recipients for this function. Copy the addresses or export the list."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/bulk_actions_dropdown.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "filtered"
|
||||
msgstr ""
|
||||
|
|
|
|||
155
test/mv_web/components/bulk_actions_dropdown_test.exs
Normal file
155
test/mv_web/components/bulk_actions_dropdown_test.exs
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
defmodule MvWeb.Components.BulkActionsDropdownTest do
|
||||
use MvWeb.ConnCase, async: true
|
||||
import Phoenix.LiveViewTest
|
||||
|
||||
alias MvWeb.Components.BulkActionsDropdown
|
||||
|
||||
defp render_open(assigns) do
|
||||
base = %{
|
||||
id: "bulk-actions-dropdown",
|
||||
open: true,
|
||||
export_payload_json: ~s({"selected_ids":[]}),
|
||||
selected_count: 0,
|
||||
scope: :all,
|
||||
mailto_bcc: "a%40example.com",
|
||||
recipient_count: 1,
|
||||
mailto_disabled?: false
|
||||
}
|
||||
|
||||
render_component(BulkActionsDropdown, Map.merge(base, assigns))
|
||||
end
|
||||
|
||||
defp scope_badge(html) do
|
||||
html
|
||||
|> LazyHTML.from_fragment()
|
||||
|> LazyHTML.query(~s([data-testid="bulk-actions-scope-badge"]))
|
||||
end
|
||||
|
||||
describe "trigger scope badge" do
|
||||
test "shows an emphasized primary count badge when members are selected" do
|
||||
html =
|
||||
render_component(BulkActionsDropdown, %{id: "d", scope: :selection, selected_count: 3})
|
||||
|
||||
badge = scope_badge(html)
|
||||
assert badge |> LazyHTML.text() |> String.trim() == "3"
|
||||
classes = badge |> LazyHTML.attribute("class") |> List.first()
|
||||
assert classes =~ "badge-primary"
|
||||
assert classes =~ "badge-sm"
|
||||
# The trigger label itself is just the bare action verb, no parenthetical.
|
||||
assert html =~ "Actions"
|
||||
refute html =~ "(3)"
|
||||
end
|
||||
|
||||
test "shows a muted neutral 'all' badge when nothing selected and no filter" do
|
||||
html = render_component(BulkActionsDropdown, %{id: "d", scope: :all, selected_count: 0})
|
||||
|
||||
badge = scope_badge(html)
|
||||
assert badge |> LazyHTML.text() |> String.trim() == "all"
|
||||
classes = badge |> LazyHTML.attribute("class") |> List.first()
|
||||
assert classes =~ "badge-neutral"
|
||||
assert classes =~ "badge-sm"
|
||||
refute html =~ "(all)"
|
||||
end
|
||||
|
||||
test "shows a muted neutral 'filtered' badge when a filter is active" do
|
||||
html =
|
||||
render_component(BulkActionsDropdown, %{id: "d", scope: :filtered, selected_count: 0})
|
||||
|
||||
badge = scope_badge(html)
|
||||
assert badge |> LazyHTML.text() |> String.trim() == "filtered"
|
||||
classes = badge |> LazyHTML.attribute("class") |> List.first()
|
||||
assert classes =~ "badge-neutral"
|
||||
assert classes =~ "badge-sm"
|
||||
refute html =~ "(filtered)"
|
||||
end
|
||||
end
|
||||
|
||||
describe "trigger affordance" do
|
||||
test "carries a trailing chevron" do
|
||||
html = render_component(BulkActionsDropdown, %{id: "d", scope: :all, selected_count: 0})
|
||||
assert html =~ "hero-chevron-down"
|
||||
end
|
||||
end
|
||||
|
||||
describe "menu item layout" do
|
||||
test "all menu items carry whitespace-nowrap to prevent label text wrapping" do
|
||||
html = render_open(%{})
|
||||
|
||||
doc = LazyHTML.from_fragment(html)
|
||||
|
||||
# Collect all elements with role="menuitem" (both <a> and <button>)
|
||||
items = LazyHTML.query(doc, ~s([role="menuitem"]))
|
||||
classes_list = LazyHTML.attribute(items, "class")
|
||||
|
||||
assert length(classes_list) >= 4,
|
||||
"expected at least 4 menu items, got #{length(classes_list)}"
|
||||
|
||||
for classes <- classes_list do
|
||||
assert classes =~ "whitespace-nowrap",
|
||||
"expected whitespace-nowrap on menu item, got class: #{inspect(classes)}"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "menu items" do
|
||||
test "lists the four bulk actions in order, flattened to one level" do
|
||||
html = render_open(%{})
|
||||
|
||||
mailto = :binary.match(html, "bulk-actions-mailto") |> elem(0)
|
||||
copy = :binary.match(html, "bulk-actions-copy") |> elem(0)
|
||||
csv = :binary.match(html, "export-csv-link") |> elem(0)
|
||||
pdf = :binary.match(html, "export-pdf-link") |> elem(0)
|
||||
|
||||
assert mailto < copy
|
||||
assert copy < csv
|
||||
assert csv < pdf
|
||||
# No nested "Export" submenu trigger — the export items sit at the top level.
|
||||
refute html =~ ~s(data-testid="export-dropdown")
|
||||
end
|
||||
|
||||
test "copy item carries the clipboard hook and an un-targeted copy_emails click" do
|
||||
html = render_open(%{})
|
||||
|
||||
assert html =~ ~s(phx-hook="CopyToClipboard")
|
||||
assert html =~ ~s(phx-click="copy_emails")
|
||||
# The copy click must NOT be targeted at the component, so it reaches the
|
||||
# parent LiveView handler.
|
||||
refute html =~ ~r/phx-click="copy_emails"[^>]*phx-target/
|
||||
end
|
||||
end
|
||||
|
||||
describe "mailto recipient cap" do
|
||||
test "mailto item is enabled below the threshold with a BCC link" do
|
||||
html = render_open(%{mailto_disabled?: false, mailto_bcc: "a%40example.com"})
|
||||
|
||||
assert html =~ ~s(href="mailto:?bcc=a%40example.com")
|
||||
refute html =~ ~r/data-testid="bulk-actions-mailto"[^>]*aria-disabled="true"/s
|
||||
end
|
||||
|
||||
test "mailto item is disabled with the explanatory tooltip at the threshold" do
|
||||
html = render_open(%{mailto_disabled?: true})
|
||||
|
||||
assert html =~ ~s(aria-disabled="true")
|
||||
assert html =~ ~s(tabindex="-1")
|
||||
assert html =~ "Too many recipients for this function"
|
||||
# Disabled mailto must not expose an actionable BCC link.
|
||||
refute html =~ "href=\"mailto:"
|
||||
end
|
||||
end
|
||||
|
||||
describe "export forms" do
|
||||
test "CSV and PDF items keep the CSRF-protected form POST and payload" do
|
||||
payload = ~s({"selected_ids":["x"]})
|
||||
html = render_open(%{export_payload_json: payload})
|
||||
|
||||
assert html =~ ~s(action="/members/export.csv")
|
||||
assert html =~ ~s(action="/members/export.pdf")
|
||||
assert html =~ ~s(name="_csrf_token")
|
||||
assert html =~ ~s(name="payload")
|
||||
# The payload lands HTML-escaped in the hidden input value attribute; both
|
||||
# export forms carry the same payload.
|
||||
escaped = Phoenix.HTML.html_escape(payload) |> Phoenix.HTML.safe_to_string()
|
||||
assert html =~ ~s(name="payload" value="#{escaped}")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -409,6 +409,19 @@ defmodule MvWeb.MemberLive.IndexTest do
|
|||
end
|
||||
end
|
||||
|
||||
# Opens the bulk-actions dropdown and clicks the copy item. The copy item only
|
||||
# exists in the DOM while the menu is open, so we toggle it open first.
|
||||
defp click_copy_via_dropdown(view) do
|
||||
view |> element(~s([data-testid="bulk-actions-button"])) |> render_click()
|
||||
view |> element("#bulk-actions-copy") |> render_click()
|
||||
end
|
||||
|
||||
defp scope_badge(html) do
|
||||
html
|
||||
|> LazyHTML.from_fragment()
|
||||
|> LazyHTML.query(~s([data-testid="bulk-actions-scope-badge"]))
|
||||
end
|
||||
|
||||
describe "copy_emails feature" do
|
||||
setup do
|
||||
system_actor = SystemActor.get_system_actor()
|
||||
|
|
@ -460,22 +473,23 @@ defmodule MvWeb.MemberLive.IndexTest do
|
|||
render_click(view, "select_member", %{"id" => member2.id})
|
||||
|
||||
# Trigger copy_emails event
|
||||
view |> element("#copy-emails-btn") |> render_click()
|
||||
click_copy_via_dropdown(view)
|
||||
|
||||
# Verify flash message shows correct count
|
||||
assert render(view) =~ "2"
|
||||
end
|
||||
|
||||
test "copy_emails event with no selection shows error flash", %{conn: conn} do
|
||||
test "copy_emails with no selection copies all members' emails", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, view, _html} = live(conn, "/members")
|
||||
|
||||
# Trigger copy_emails event directly (button not visible when no selection)
|
||||
# This tests the edge case where event is triggered without selection
|
||||
# Deliberate behaviour change (§3.1): with no selection, copy operates on
|
||||
# the current scope (all members) instead of erroring "No members selected".
|
||||
result = render_hook(view, "copy_emails", %{})
|
||||
|
||||
# Should show error flash
|
||||
assert result =~ "No members selected" or result =~ "Keine Mitglieder"
|
||||
# Three seeded members all have an email → success flash, not an error.
|
||||
assert result =~ "3"
|
||||
refute result =~ "No members selected"
|
||||
end
|
||||
|
||||
test "copy_emails event with all members selected formats all emails", %{
|
||||
|
|
@ -488,7 +502,7 @@ defmodule MvWeb.MemberLive.IndexTest do
|
|||
view |> element("[phx-click='select_all']") |> render_click()
|
||||
|
||||
# Trigger copy_emails event
|
||||
view |> element("#copy-emails-btn") |> render_click()
|
||||
click_copy_via_dropdown(view)
|
||||
|
||||
# Verify flash message shows correct count (3 members)
|
||||
assert render(view) =~ "3"
|
||||
|
|
@ -505,7 +519,7 @@ defmodule MvWeb.MemberLive.IndexTest do
|
|||
render_click(view, "select_member", %{"id" => member3.id})
|
||||
|
||||
# Trigger copy_emails event - should not crash
|
||||
view |> element("#copy-emails-btn") |> render_click()
|
||||
click_copy_via_dropdown(view)
|
||||
|
||||
# Verify flash message shows success
|
||||
assert render(view) =~ "1"
|
||||
|
|
@ -582,37 +596,38 @@ defmodule MvWeb.MemberLive.IndexTest do
|
|||
|
||||
# The format should be "Test Format <test.format@example.com>"
|
||||
# We verify this by checking the flash shows 1 email was copied
|
||||
view |> element("#copy-emails-btn") |> render_click()
|
||||
click_copy_via_dropdown(view)
|
||||
assert render(view) =~ "1"
|
||||
end
|
||||
|
||||
test "copy button is disabled when no members selected", %{conn: conn} do
|
||||
test "copy and mailto items stay actionable with no selection", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, view, _html} = live(conn, "/members")
|
||||
|
||||
# Copy button should be disabled (button element)
|
||||
assert has_element?(view, "#copy-emails-btn[disabled]")
|
||||
# Open email button should be disabled (link with tabindex and aria-disabled)
|
||||
assert has_element?(view, "#open-email-btn[tabindex='-1'][aria-disabled='true']")
|
||||
# Open the dropdown so its items are in the DOM.
|
||||
view |> element(~s([data-testid="bulk-actions-button"])) |> render_click()
|
||||
|
||||
# Deliberate behaviour change (§3.1): items are never disabled merely
|
||||
# because nothing is selected. Copy is a plain button (no disabled attr),
|
||||
# and mailto is an enabled link (no aria-disabled) carrying a BCC of all
|
||||
# three seeded members.
|
||||
refute has_element?(view, ~s([data-testid="bulk-actions-copy"][disabled]))
|
||||
refute has_element?(view, ~s([data-testid="bulk-actions-mailto"][aria-disabled="true"]))
|
||||
assert has_element?(view, ~s([data-testid="bulk-actions-mailto"][href^="mailto:"]))
|
||||
end
|
||||
|
||||
test "copy button is enabled after selection", %{
|
||||
test "trigger shows the selected count after a selection", %{
|
||||
conn: conn,
|
||||
member1: member1
|
||||
} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, view, _html} = live(conn, "/members")
|
||||
|
||||
# Select a member by sending the select_member event directly
|
||||
render_click(view, "select_member", %{"id" => member1.id})
|
||||
|
||||
# Copy button should now be enabled (no disabled attribute)
|
||||
refute has_element?(view, "#copy-emails-btn[disabled]")
|
||||
# Open email button should now be enabled (no tabindex=-1 or aria-disabled)
|
||||
refute has_element?(view, "#open-email-btn[tabindex='-1']")
|
||||
refute has_element?(view, "#open-email-btn[aria-disabled='true']")
|
||||
# Counter should show correct count
|
||||
assert render(view) =~ "1"
|
||||
# The scope badge on the trigger reflects the selection count.
|
||||
badge = scope_badge(render(view))
|
||||
assert badge |> LazyHTML.text() |> String.trim() == "1"
|
||||
end
|
||||
|
||||
test "copy button click triggers event and shows flash", %{
|
||||
|
|
@ -626,14 +641,47 @@ defmodule MvWeb.MemberLive.IndexTest do
|
|||
render_click(view, "select_member", %{"id" => member1.id})
|
||||
|
||||
# Click copy button
|
||||
view |> element("#copy-emails-btn") |> render_click()
|
||||
click_copy_via_dropdown(view)
|
||||
|
||||
# Flash message should appear
|
||||
assert has_element?(view, "#flash-group")
|
||||
end
|
||||
|
||||
test "copy excludes a member whose email is blank from the recipient list", %{conn: conn} do
|
||||
# The Member create action requires an email, so a blank-email member cannot
|
||||
# be persisted; we exercise the preserved defensive filter in
|
||||
# format_selected_member_emails/2 directly. One member has an email, the
|
||||
# other has a blank one — only the former is a recipient (§1.10).
|
||||
with_email = %{
|
||||
id: Ecto.UUID.generate(),
|
||||
first_name: "Has",
|
||||
last_name: "Mail",
|
||||
email: "has@example.com"
|
||||
}
|
||||
|
||||
blank_email = %{id: Ecto.UUID.generate(), first_name: "Blank", last_name: "Mail", email: ""}
|
||||
selected = MapSet.new([with_email.id, blank_email.id])
|
||||
|
||||
emails = MemberIndex.format_selected_member_emails([with_email, blank_email], selected)
|
||||
|
||||
assert emails == ["Has Mail <has@example.com>"]
|
||||
_ = conn
|
||||
end
|
||||
end
|
||||
|
||||
describe "export dropdown" do
|
||||
describe "copy_emails empty-recipient feedback" do
|
||||
test "copy with zero recipients shows 'No email addresses found'", %{conn: conn} do
|
||||
# No members exist → the no-selection scope yields zero recipients, so the
|
||||
# preserved empty-recipient feedback fires instead of a clipboard push (§1.11).
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, view, _html} = live(conn, "/members")
|
||||
|
||||
result = render_hook(view, "copy_emails", %{})
|
||||
assert result =~ "No email addresses found" or result =~ "Keine E-Mail"
|
||||
end
|
||||
end
|
||||
|
||||
describe "bulk-actions dropdown" do
|
||||
setup do
|
||||
system_actor = SystemActor.get_system_actor()
|
||||
|
||||
|
|
@ -646,27 +694,72 @@ defmodule MvWeb.MemberLive.IndexTest do
|
|||
%{member1: m1}
|
||||
end
|
||||
|
||||
test "export dropdown button is rendered when no selection and shows (all)", %{conn: conn} do
|
||||
test "trigger is rendered with a muted 'all' scope badge when no selection", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, _view, html} = live(conn, "/members")
|
||||
|
||||
# Dropdown button should be present
|
||||
assert html =~ ~s(data-testid="export-dropdown")
|
||||
assert html =~ ~s(data-testid="export-dropdown-button")
|
||||
assert html =~ "Export"
|
||||
# Button text shows "all" when 0 selected (locale-dependent)
|
||||
assert html =~ "all" or html =~ "All"
|
||||
# The single bulk-actions trigger is present.
|
||||
assert html =~ ~s(data-testid="bulk-actions-dropdown")
|
||||
assert html =~ ~s(data-testid="bulk-actions-button")
|
||||
# The scope is shown as a badge, not a parenthetical text suffix. The test
|
||||
# locale renders the English msgids (German wording lives in de.po):
|
||||
# "Actions" -> "Aktionen", "all" -> "alle".
|
||||
assert html =~ "Actions"
|
||||
refute html =~ "(all)"
|
||||
|
||||
badge = scope_badge(html)
|
||||
assert badge |> LazyHTML.text() |> String.trim() == "all"
|
||||
classes = badge |> LazyHTML.attribute("class") |> List.first()
|
||||
assert classes =~ "badge-neutral"
|
||||
assert classes =~ "badge-sm"
|
||||
end
|
||||
|
||||
test "after select_member event export dropdown shows (1)", %{conn: conn, member1: member1} do
|
||||
test "trigger shows an emphasized count badge after select_member", %{
|
||||
conn: conn,
|
||||
member1: member1
|
||||
} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, view, _html} = live(conn, "/members")
|
||||
|
||||
render_click(view, "select_member", %{"id" => member1.id})
|
||||
|
||||
html = render(view)
|
||||
assert html =~ "Export"
|
||||
assert html =~ "(1)"
|
||||
assert html =~ "Actions"
|
||||
refute html =~ "(1)"
|
||||
|
||||
badge = scope_badge(html)
|
||||
assert badge |> LazyHTML.text() |> String.trim() == "1"
|
||||
classes = badge |> LazyHTML.attribute("class") |> List.first()
|
||||
assert classes =~ "badge-primary"
|
||||
assert classes =~ "badge-sm"
|
||||
end
|
||||
|
||||
test "trigger shows a muted 'filtered' badge when a search narrows the list", %{
|
||||
conn: conn
|
||||
} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, _view, html} = live(conn, "/members?query=Nobody")
|
||||
|
||||
assert html =~ "Actions"
|
||||
refute html =~ "(filtered)"
|
||||
|
||||
badge = scope_badge(html)
|
||||
assert badge |> LazyHTML.text() |> String.trim() == "filtered"
|
||||
classes = badge |> LazyHTML.attribute("class") |> List.first()
|
||||
assert classes =~ "badge-neutral"
|
||||
assert classes =~ "badge-sm"
|
||||
end
|
||||
|
||||
test "trigger carries a trailing chevron", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, view, html} = live(conn, "/members")
|
||||
|
||||
assert html =~ "hero-chevron-down"
|
||||
|
||||
# The bulk-actions trigger and the bespoke member-filter trigger each
|
||||
# carry their own chevron; assert the filter trigger's chevron is pinned
|
||||
# independently, so removing it from the filter component fails this test.
|
||||
assert has_element?(view, ".member-filter-dropdown .hero-chevron-down")
|
||||
end
|
||||
|
||||
test "dropdown opens and closes on click", %{conn: conn} do
|
||||
|
|
@ -674,23 +767,23 @@ defmodule MvWeb.MemberLive.IndexTest do
|
|||
{:ok, view, _html} = live(conn, "/members")
|
||||
|
||||
# Initially closed
|
||||
refute has_element?(view, ~s([data-testid="export-dropdown-menu"]))
|
||||
refute has_element?(view, ~s([data-testid="bulk-actions-menu"]))
|
||||
|
||||
# Click to open
|
||||
view
|
||||
|> element(~s([data-testid="export-dropdown-button"]))
|
||||
|> element(~s([data-testid="bulk-actions-button"]))
|
||||
|> render_click()
|
||||
|
||||
# Menu should be visible
|
||||
assert has_element?(view, ~s([data-testid="export-dropdown-menu"]))
|
||||
assert has_element?(view, ~s([data-testid="bulk-actions-menu"]))
|
||||
|
||||
# Click to close
|
||||
view
|
||||
|> element(~s([data-testid="export-dropdown-button"]))
|
||||
|> element(~s([data-testid="bulk-actions-button"]))
|
||||
|> render_click()
|
||||
|
||||
# Menu should be hidden
|
||||
refute has_element?(view, ~s([data-testid="export-dropdown-menu"]))
|
||||
refute has_element?(view, ~s([data-testid="bulk-actions-menu"]))
|
||||
end
|
||||
|
||||
test "dropdown has click-away and ESC handlers", %{conn: conn} do
|
||||
|
|
@ -699,11 +792,11 @@ defmodule MvWeb.MemberLive.IndexTest do
|
|||
|
||||
# Open dropdown
|
||||
view
|
||||
|> element(~s([data-testid="export-dropdown-button"]))
|
||||
|> element(~s([data-testid="bulk-actions-button"]))
|
||||
|> render_click()
|
||||
|
||||
html = render(view)
|
||||
assert has_element?(view, ~s([data-testid="export-dropdown-menu"]))
|
||||
assert has_element?(view, ~s([data-testid="bulk-actions-menu"]))
|
||||
|
||||
# Check that click-away handler is present
|
||||
assert html =~ ~s(phx-click-away="close_dropdown")
|
||||
|
|
@ -712,13 +805,37 @@ defmodule MvWeb.MemberLive.IndexTest do
|
|||
assert html =~ ~s(phx-key="Escape")
|
||||
end
|
||||
|
||||
test "dropdown menu contains CSV and PDF export links with correct payload", %{conn: conn} do
|
||||
test "menu lists the four actions in order, flattened to one level", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, view, _html} = live(conn, "/members")
|
||||
|
||||
view |> element(~s([data-testid="bulk-actions-button"])) |> render_click()
|
||||
html = render(view)
|
||||
|
||||
# All four items present.
|
||||
assert has_element?(view, ~s([data-testid="bulk-actions-mailto"]))
|
||||
assert has_element?(view, ~s([data-testid="bulk-actions-copy"]))
|
||||
assert has_element?(view, ~s([data-testid="export-csv-link"]))
|
||||
assert has_element?(view, ~s([data-testid="export-pdf-link"]))
|
||||
|
||||
# In order: mailto, copy, CSV, PDF.
|
||||
mailto = :binary.match(html, "bulk-actions-mailto") |> elem(0)
|
||||
copy = :binary.match(html, "bulk-actions-copy") |> elem(0)
|
||||
csv = :binary.match(html, "export-csv-link") |> elem(0)
|
||||
pdf = :binary.match(html, "export-pdf-link") |> elem(0)
|
||||
assert mailto < copy and copy < csv and csv < pdf
|
||||
|
||||
# No nested export submenu — the former standalone export dropdown is gone.
|
||||
refute html =~ ~s(data-testid="export-dropdown")
|
||||
end
|
||||
|
||||
test "menu contains CSV and PDF export forms with identical payload and CSRF", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, view, _html} = live(conn, "/members")
|
||||
|
||||
# Open dropdown
|
||||
view
|
||||
|> element(~s([data-testid="export-dropdown-button"]))
|
||||
|> element(~s([data-testid="bulk-actions-button"]))
|
||||
|> render_click()
|
||||
|
||||
html = render(view)
|
||||
|
|
@ -756,11 +873,11 @@ defmodule MvWeb.MemberLive.IndexTest do
|
|||
# Button should have aria-expanded="false" when closed
|
||||
assert html =~ ~s(aria-expanded="false")
|
||||
# Button should have aria-controls pointing to menu
|
||||
assert html =~ ~s(aria-controls="export-dropdown-menu")
|
||||
assert html =~ ~s(aria-controls="bulk-actions-dropdown-menu")
|
||||
|
||||
# Open dropdown
|
||||
view
|
||||
|> element(~s([data-testid="export-dropdown-button"]))
|
||||
|> element(~s([data-testid="bulk-actions-button"]))
|
||||
|> render_click()
|
||||
|
||||
html = render(view)
|
||||
|
|
@ -782,6 +899,172 @@ defmodule MvWeb.MemberLive.IndexTest do
|
|||
end
|
||||
end
|
||||
|
||||
describe "bulk-actions header layout" do
|
||||
test "header renders one bulk-actions trigger and no standalone copy/mailto/export controls",
|
||||
%{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, view, html} = live(conn, "/members")
|
||||
|
||||
# Exactly one bulk-actions trigger.
|
||||
assert has_element?(view, ~s([data-testid="bulk-actions-button"]))
|
||||
|
||||
# The former standalone controls are gone as top-level header buttons.
|
||||
refute html =~ ~s(id="copy-emails-btn")
|
||||
refute html =~ ~s(id="open-email-btn")
|
||||
refute html =~ ~s(data-testid="export-dropdown-button")
|
||||
end
|
||||
|
||||
test "New Member stays a separate primary button outside the dropdown", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, view, html} = live(conn, "/members")
|
||||
|
||||
# New Member is present as its own control...
|
||||
assert has_element?(view, ~s([data-testid="member-new"]))
|
||||
|
||||
# ...and it is not an item of the bulk-actions menu (it precedes the menu
|
||||
# markup but is not nested inside it).
|
||||
refute html =~ ~r/data-testid="bulk-actions-menu".*data-testid="member-new"/s
|
||||
end
|
||||
end
|
||||
|
||||
describe "mailto recipient cap on the page" do
|
||||
# Seeds n members, each with a distinct email so they all count as mailto
|
||||
# recipients in the no-selection scope.
|
||||
defp seed_members_with_email(n) do
|
||||
system_actor = SystemActor.get_system_actor()
|
||||
|
||||
Enum.each(1..n, fn i ->
|
||||
{:ok, _} =
|
||||
Membership.create_member(
|
||||
%{
|
||||
first_name: "Bulk",
|
||||
last_name: "M#{i}",
|
||||
email: "bulk#{i}@example.com"
|
||||
},
|
||||
actor: system_actor
|
||||
)
|
||||
end)
|
||||
end
|
||||
|
||||
test "mailto item is enabled just below the threshold", %{conn: conn} do
|
||||
seed_members_with_email(Mv.Constants.max_mailto_bulk_recipients() - 1)
|
||||
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, view, _html} = live(conn, "/members")
|
||||
|
||||
view |> element(~s([data-testid="bulk-actions-button"])) |> render_click()
|
||||
|
||||
# 49 recipients → enabled, with an actionable BCC link, no aria-disabled.
|
||||
assert has_element?(view, ~s([data-testid="bulk-actions-mailto"][href^="mailto:"]))
|
||||
refute has_element?(view, ~s([data-testid="bulk-actions-mailto"][aria-disabled="true"]))
|
||||
end
|
||||
|
||||
test "mailto item is disabled with tooltip at the threshold", %{conn: conn} do
|
||||
seed_members_with_email(Mv.Constants.max_mailto_bulk_recipients())
|
||||
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, view, _html} = live(conn, "/members")
|
||||
|
||||
view |> element(~s([data-testid="bulk-actions-button"])) |> render_click()
|
||||
html = render(view)
|
||||
|
||||
# 50 recipients → disabled, with the explanatory tooltip and no BCC link.
|
||||
# The tooltip renders the English msgid in the test locale (German wording
|
||||
# is "Zu viele Empfänger für diese Funktion. ..." in de.po).
|
||||
assert has_element?(view, ~s([data-testid="bulk-actions-mailto"][aria-disabled="true"]))
|
||||
assert html =~ "Too many recipients for this function"
|
||||
refute has_element?(view, ~s([data-testid="bulk-actions-mailto"][href^="mailto:"]))
|
||||
end
|
||||
end
|
||||
|
||||
describe "scope-aware selection assigns" do
|
||||
setup do
|
||||
system_actor = SystemActor.get_system_actor()
|
||||
|
||||
{:ok, m1} =
|
||||
Membership.create_member(
|
||||
%{first_name: "Scope", last_name: "One", email: "scope1@example.com"},
|
||||
actor: system_actor
|
||||
)
|
||||
|
||||
{:ok, m2} =
|
||||
Membership.create_member(
|
||||
%{first_name: "Scope", last_name: "Two", email: "scope2@example.com"},
|
||||
actor: system_actor
|
||||
)
|
||||
|
||||
%{member1: m1, member2: m2}
|
||||
end
|
||||
|
||||
test "scope is :all when nothing selected and no filter", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, view, _html} = live(conn, "/members")
|
||||
|
||||
assigns = :sys.get_state(view.pid).socket.assigns
|
||||
assert assigns.scope == :all
|
||||
end
|
||||
|
||||
test "scope is :filtered when a search term is active", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, view, _html} = live(conn, "/members?query=Scope")
|
||||
|
||||
assigns = :sys.get_state(view.pid).socket.assigns
|
||||
assert assigns.scope == :filtered
|
||||
end
|
||||
|
||||
test "scope is :filtered when a non-search filter is active", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, view, html} = live(conn, "/members?cycle_status_filter=paid")
|
||||
|
||||
assigns = :sys.get_state(view.pid).socket.assigns
|
||||
assert assigns.scope == :filtered
|
||||
|
||||
badge = scope_badge(html)
|
||||
assert badge |> LazyHTML.text() |> String.trim() == "filtered"
|
||||
classes = badge |> LazyHTML.attribute("class") |> List.first()
|
||||
assert classes =~ "badge-neutral"
|
||||
assert classes =~ "badge-sm"
|
||||
end
|
||||
|
||||
test "scope is :selection when a member is selected", %{conn: conn, member1: member1} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, view, _html} = live(conn, "/members")
|
||||
|
||||
render_click(view, "select_member", %{"id" => member1.id})
|
||||
|
||||
assigns = :sys.get_state(view.pid).socket.assigns
|
||||
assert assigns.scope == :selection
|
||||
end
|
||||
|
||||
test "with no selection, recipient_count and mailto_bcc cover all members", %{
|
||||
conn: conn
|
||||
} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, view, _html} = live(conn, "/members")
|
||||
|
||||
assigns = :sys.get_state(view.pid).socket.assigns
|
||||
# Both seeded members have an email, so the no-selection scope covers both.
|
||||
assert assigns.recipient_count == 2
|
||||
assert assigns.mailto_bcc =~ "scope1%40example.com"
|
||||
assert assigns.mailto_bcc =~ "scope2%40example.com"
|
||||
end
|
||||
|
||||
test "with a selection, recipient_count and mailto_bcc cover only the selection", %{
|
||||
conn: conn,
|
||||
member1: member1
|
||||
} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, view, _html} = live(conn, "/members")
|
||||
|
||||
render_click(view, "select_member", %{"id" => member1.id})
|
||||
|
||||
assigns = :sys.get_state(view.pid).socket.assigns
|
||||
assert assigns.recipient_count == 1
|
||||
assert assigns.mailto_bcc =~ "scope1%40example.com"
|
||||
refute assigns.mailto_bcc =~ "scope2%40example.com"
|
||||
end
|
||||
end
|
||||
|
||||
describe "cycle status filter" do
|
||||
# Helper to create a member (only used in this describe block)
|
||||
defp create_member(attrs, actor) do
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue