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,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))
|
||||
|
|
|
|||
|
|
@ -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")}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue