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:
Simon 2026-06-04 16:44:13 +02:00
parent 8e5dd7e4c6
commit c983c8d5bb
10 changed files with 920 additions and 330 deletions

View file

@ -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,41 +250,42 @@ 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"))}
if email_count == 0 do
{:noreply, put_flash(socket, :error, gettext("No email addresses found"))}
else
# RFC 5322 uses comma as separator for email address lists
email_string = Enum.join(formatted_emails, ", ")
email_count == 0 ->
{:noreply, put_flash(socket, :error, gettext("No email addresses found"))}
true ->
# RFC 5322 uses comma as separator for email address lists
email_string = Enum.join(formatted_emails, ", ")
socket =
socket
|> push_event("copy_to_clipboard", %{text: email_string})
|> put_flash(
:success,
ngettext(
"Copied %{count} email address to clipboard",
"Copied %{count} email addresses to clipboard",
email_count,
count: email_count
)
)
|> put_flash(
:warning,
gettext("Tip: Paste email addresses into the BCC field for privacy compliance")
socket =
socket
|> push_event("copy_to_clipboard", %{text: email_string})
|> put_flash(
:success,
ngettext(
"Copied %{count} email address to clipboard",
"Copied %{count} email addresses to clipboard",
email_count,
count: email_count
)
)
|> put_flash(
:warning,
gettext("Tip: Paste email addresses into the BCC field for privacy compliance")
)
{:noreply, socket}
{:noreply, socket}
end
end
@ -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)
|> Enum.join(", ")
|> URI.encode_www_form()
|> String.replace("+", "%20")
else
""
end
recipient_emails
|> Enum.join(", ")
|> URI.encode_www_form()
|> String.replace("+", "%20")
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))

View file

@ -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")}