Collect Bulk Actions in Dropdown #524
16 changed files with 1029 additions and 326 deletions
|
|
@ -17,6 +17,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
- **Dynamic CSV import templates** – The EN and DE import-template downloads now include the association's current custom fields instead of a fixed column set.
|
||||
|
||||
### Changed
|
||||
- **Member bulk actions in one menu** – The actions above the member overview (open in email program, copy email addresses, export to CSV, export to PDF) are now collected in a single "Aktionen" dropdown instead of separate buttons. Without a selection they apply to all members, or to the currently filtered members; the trigger shows the active scope. Opening the email program is disabled when too many recipients are selected, with a hint to copy the addresses or use the export instead.
|
||||
- **Dropdown buttons** – Dropdown buttons (actions, filter, column visibility) now show a chevron so they are recognizable as menus.
|
||||
- **Default GDPR custom field** – The seeded GDPR field was shortened from "Datenschutzerklärung akzeptiert" to "DSGVO" and now ships with a default join-form description (with a placeholder link to replace).
|
||||
|
||||
### Fixed
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -464,6 +464,9 @@ defmodule MvWeb.CoreComponents do
|
|||
|
||||
slot :inner_block, doc: "Custom content for the dropdown menu (e.g., forms)"
|
||||
|
||||
slot :trigger_badge,
|
||||
doc: "Optional badge rendered in the trigger after the label (e.g. a scope badge)"
|
||||
|
||||
def dropdown_menu(assigns) do
|
||||
menu_testid = assigns.menu_testid || "#{assigns.testid}-menu"
|
||||
|
||||
|
|
@ -498,6 +501,8 @@ defmodule MvWeb.CoreComponents do
|
|||
<.icon name={@icon} />
|
||||
<% end %>
|
||||
<span>{@button_label}</span>
|
||||
{render_slot(@trigger_badge)}
|
||||
<.icon name="hero-chevron-down" class="size-4" />
|
||||
</button>
|
||||
|
||||
<ul
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -156,6 +156,7 @@ defmodule MvWeb.Components.MemberFilterComponent do
|
|||
>
|
||||
{@member_count}
|
||||
</.badge>
|
||||
<.icon name="hero-chevron-down" class="size-4" />
|
||||
</.button>
|
||||
|
||||
<!--
|
||||
|
|
|
|||
|
|
@ -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")}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
@ -670,16 +666,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."
|
||||
|
|
@ -1198,22 +1189,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"
|
||||
|
|
@ -2206,11 +2192,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."
|
||||
|
|
@ -2364,12 +2345,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"
|
||||
|
|
@ -2395,11 +2371,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
|
||||
|
|
@ -3574,7 +3545,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"
|
||||
|
|
@ -4091,3 +4062,53 @@ msgstr "Zeile 2"
|
|||
#, elixir-autogen, elixir-format
|
||||
msgid "Row 3"
|
||||
msgstr "Zeile 3"
|
||||
|
||||
#: lib/mv_web/components/bulk_actions_dropdown.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Export to CSV"
|
||||
msgstr "Mitglieder als CSV exportieren"
|
||||
|
||||
#: lib/mv_web/components/bulk_actions_dropdown.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Export to PDF"
|
||||
msgstr "Mitglieder 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"
|
||||
|
||||
#~ #: lib/mv_web/components/export_dropdown.ex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "CSV"
|
||||
#~ msgstr "CSV"
|
||||
|
||||
#~ #: 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/components/export_dropdown.ex
|
||||
#~ #, elixir-autogen, elixir-format, fuzzy
|
||||
#~ msgid "Export"
|
||||
#~ msgstr "Export"
|
||||
|
||||
#~ #: 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/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/components/export_dropdown.ex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "PDF"
|
||||
#~ msgstr "PDF"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
@ -671,16 +667,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."
|
||||
|
|
@ -1199,22 +1190,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 ""
|
||||
|
|
@ -2207,11 +2193,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."
|
||||
|
|
@ -2365,12 +2346,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 ""
|
||||
|
|
@ -2396,11 +2372,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
|
||||
|
|
@ -3574,7 +3545,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 ""
|
||||
|
|
@ -4091,3 +4062,23 @@ msgstr ""
|
|||
#, elixir-autogen, elixir-format
|
||||
msgid "Row 3"
|
||||
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"
|
||||
|
|
@ -671,16 +667,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."
|
||||
|
|
@ -1199,22 +1190,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 ""
|
||||
|
|
@ -2207,11 +2193,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."
|
||||
|
|
@ -2365,12 +2346,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 ""
|
||||
|
|
@ -2396,11 +2372,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
|
||||
|
|
@ -3574,7 +3545,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 ""
|
||||
|
|
@ -4091,3 +4062,53 @@ msgstr ""
|
|||
#, elixir-autogen, elixir-format
|
||||
msgid "Row 3"
|
||||
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 ""
|
||||
|
||||
#~ #: lib/mv_web/components/export_dropdown.ex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "CSV"
|
||||
#~ 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/components/export_dropdown.ex
|
||||
#~ #, elixir-autogen, elixir-format, fuzzy
|
||||
#~ msgid "Export"
|
||||
#~ 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/index.html.heex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "Open email program with BCC recipients"
|
||||
#~ msgstr ""
|
||||
|
||||
#~ #: lib/mv_web/components/export_dropdown.ex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "PDF"
|
||||
#~ 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
|
||||
|
|
@ -17,5 +17,13 @@ defmodule MvWeb.Components.FieldVisibilityDropdownComponentTest do
|
|||
assert has_element?(view, "button[phx-click='select_all']")
|
||||
assert has_element?(view, "button[phx-click='select_none']")
|
||||
end
|
||||
|
||||
test "trigger carries a trailing chevron affordance", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, _view, html} = live(conn, ~p"/members")
|
||||
|
||||
# The shared dropdown trigger signals "opens a menu" with a trailing chevron.
|
||||
assert html =~ "hero-chevron-down"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -49,6 +49,20 @@ defmodule MvWeb.Components.MemberFilterComponentTest do
|
|||
end
|
||||
|
||||
describe "rendering" do
|
||||
test "trigger carries a trailing chevron affordance", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, _view, html} = live(conn, "/members")
|
||||
|
||||
# Mirror the shared dropdown affordance: a trailing chevron inside the
|
||||
# bespoke filter trigger button.
|
||||
chevron =
|
||||
html
|
||||
|> LazyHTML.from_fragment()
|
||||
|> LazyHTML.query(~s(#member-filter button[aria-haspopup="true"] .hero-chevron-down))
|
||||
|
||||
assert Enum.count(chevron) == 1
|
||||
end
|
||||
|
||||
test "renders boolean custom fields when present", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
boolean_field = create_boolean_custom_field(%{name: "Active Member"})
|
||||
|
|
|
|||
|
|
@ -82,8 +82,10 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
|
|||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, _view, html} = live(conn, "/members?query=&sort_field=email&sort_order=desc")
|
||||
|
||||
# Count occurrences to ensure only one descending icon
|
||||
down_count = html |> String.split("hero-chevron-down ") |> length() |> Kernel.-(1)
|
||||
# Count occurrences to ensure only one descending sort icon. Dropdown
|
||||
# triggers carry their own trailing "hero-chevron-down size-4" chevron, so
|
||||
# the sort-active icon is identified by its bare class (no size-4 suffix).
|
||||
down_count = active_sort_down_count(html)
|
||||
# Should be exactly one chevrondown icon
|
||||
assert down_count == 1
|
||||
end
|
||||
|
|
@ -158,7 +160,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
|
|||
|
||||
# Count active icons (should be exactly 1 - ascending for default sort field)
|
||||
up_count = html_neutral |> String.split("hero-chevron-up ") |> length() |> Kernel.-(1)
|
||||
down_count = html_neutral |> String.split("hero-chevron-down ") |> length() |> Kernel.-(1)
|
||||
down_count = active_sort_down_count(html_neutral)
|
||||
|
||||
assert up_count == 1, "Expected exactly 1 ascending icon, got #{up_count}"
|
||||
assert down_count == 0, "Expected 0 descending icons, got #{down_count}"
|
||||
|
|
@ -167,13 +169,24 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
|
|||
{:ok, _view, html_desc} = live(conn, "/members?sort_field=first_name&sort_order=desc")
|
||||
|
||||
up_count = html_desc |> String.split("hero-chevron-up ") |> length() |> Kernel.-(1)
|
||||
down_count = html_desc |> String.split("hero-chevron-down ") |> length() |> Kernel.-(1)
|
||||
down_count = active_sort_down_count(html_desc)
|
||||
|
||||
assert up_count == 0, "Expected 0 ascending icons, got #{up_count}"
|
||||
assert down_count == 1, "Expected exactly 1 descending icon, got #{down_count}"
|
||||
end
|
||||
end
|
||||
|
||||
# Counts only the descending chevron icons that belong to a sort header. Both
|
||||
# the sort-active icon and the dropdown-trigger chevron render as
|
||||
# "hero-chevron-down size-4", so they are told apart by their containing
|
||||
# button: sort headers carry phx-click="sort", dropdown triggers do not.
|
||||
defp active_sort_down_count(html) do
|
||||
html
|
||||
|> LazyHTML.from_fragment()
|
||||
|> LazyHTML.query(~s(button[phx-click="sort"] .hero-chevron-down))
|
||||
|> Enum.count()
|
||||
end
|
||||
|
||||
describe "accessibility" do
|
||||
test "sets aria-label correctly for unsorted state", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
|
|
|
|||
|
|
@ -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