mitgliederverwaltung/lib/mv_web/components/bulk_actions_dropdown.ex
Simon 9b7368d0a3
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/promote/production Build is passing
fix: width of bulk action
2026-06-08 11:44:26 +02:00

243 lines
8.7 KiB
Elixir

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-70"
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