243 lines
8.7 KiB
Elixir
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
|