From c983c8d5bb67265317946e11343e14ba050d8d4c Mon Sep 17 00:00:00 2001 From: Simon Date: Thu, 4 Jun 2026 16:44:13 +0200 Subject: [PATCH] feat(member): collect member-overview bulk actions into a single dropdown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- lib/mv/constants.ex | 17 + .../components/bulk_actions_dropdown.ex | 243 ++++++++++++ lib/mv_web/components/export_dropdown.ex | 110 ----- lib/mv_web/live/member_live/index.ex | 132 ++++-- lib/mv_web/live/member_live/index.html.heex | 29 +- priv/gettext/de/LC_MESSAGES/default.po | 64 ++- priv/gettext/default.pot | 61 ++- priv/gettext/en/LC_MESSAGES/default.po | 64 ++- .../components/bulk_actions_dropdown_test.exs | 155 ++++++++ test/mv_web/member_live/index_test.exs | 375 +++++++++++++++--- 10 files changed, 920 insertions(+), 330 deletions(-) create mode 100644 lib/mv_web/components/bulk_actions_dropdown.ex delete mode 100644 lib/mv_web/components/export_dropdown.ex create mode 100644 test/mv_web/components/bulk_actions_dropdown_test.exs diff --git a/lib/mv/constants.ex b/lib/mv/constants.ex index 4d09c89..657aa9b 100644 --- a/lib/mv/constants.ex +++ b/lib/mv/constants.ex @@ -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). diff --git a/lib/mv_web/components/bulk_actions_dropdown.ex b/lib/mv_web/components/bulk_actions_dropdown.ex new file mode 100644 index 0000000..c6f64d4 --- /dev/null +++ b/lib/mv_web/components/bulk_actions_dropdown.ex @@ -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 `
` 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""" +
+ <.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} + + +
  • + <.mailto_item mailto_bcc={@mailto_bcc} disabled={@mailto_disabled?} /> +
  • +
  • + +
  • +
  • + + + + +
  • + +
  • +
    + + + +
    +
  • + +
    + """ + 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""" + + <.icon name="hero-envelope" class="h-4 w-4" /> + {gettext("Open in email program")} + + """ + end + + defp mailto_item(%{disabled: false} = assigns) do + assigns = assign(assigns, :item_class, dropdown_item_class()) + + ~H""" + @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" /> + {gettext("Open in email program")} + + """ + 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 diff --git a/lib/mv_web/components/export_dropdown.ex b/lib/mv_web/components/export_dropdown.ex deleted file mode 100644 index 1e08168..0000000 --- a/lib/mv_web/components/export_dropdown.ex +++ /dev/null @@ -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""" -
    - <.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" - > -
  • -
    - - - -
    -
  • -
  • -
    - - - -
    -
  • - -
    - """ - 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 diff --git a/lib/mv_web/live/member_live/index.ex b/lib/mv_web/live/member_live/index.ex index 6196fc4..dc15ba0 100644 --- a/lib/mv_web/live/member_live/index.ex +++ b/lib/mv_web/live/member_live/index.ex @@ -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 " 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)) diff --git a/lib/mv_web/live/member_live/index.html.heex b/lib/mv_web/live/member_live/index.html.heex index 13dc89e..cd2ef32 100644 --- a/lib/mv_web/live/member_live/index.html.heex +++ b/lib/mv_web/live/member_live/index.html.heex @@ -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 - 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")} - <%= 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")} diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po index 81d91f7..9c30475 100644 --- a/priv/gettext/de/LC_MESSAGES/default.po +++ b/priv/gettext/de/LC_MESSAGES/default.po @@ -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" @@ -669,16 +665,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." @@ -1197,22 +1188,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" @@ -2203,11 +2189,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." @@ -2361,12 +2342,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" @@ -2392,11 +2368,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 @@ -3575,7 +3546,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" @@ -3968,7 +3939,22 @@ msgstr "Zeitraum" msgid "To" msgstr "Bis" -#~ #: lib/mv_web/live/group_live/show.ex -#~ #, elixir-autogen, elixir-format -#~ msgid "No members selected." -#~ msgstr "Keine Mitglieder ausgewählt." +#: lib/mv_web/components/bulk_actions_dropdown.ex +#, elixir-autogen, elixir-format +msgid "Export to CSV" +msgstr "Als CSV exportieren" + +#: lib/mv_web/components/bulk_actions_dropdown.ex +#, elixir-autogen, elixir-format +msgid "Export to PDF" +msgstr "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" diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot index 5e9abca..48a7f84 100644 --- a/priv/gettext/default.pot +++ b/priv/gettext/default.pot @@ -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" @@ -670,16 +666,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." @@ -1198,22 +1189,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 "" @@ -2204,11 +2190,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." @@ -2362,12 +2343,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 "" @@ -2393,11 +2369,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 @@ -3575,7 +3546,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 "" @@ -3967,3 +3938,23 @@ msgstr "" #, elixir-autogen, elixir-format msgid "To" 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 "" diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po index 1ae6a49..292ddb6 100644 --- a/priv/gettext/en/LC_MESSAGES/default.po +++ b/priv/gettext/en/LC_MESSAGES/default.po @@ -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" @@ -670,16 +666,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." @@ -1198,22 +1189,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 "" @@ -2204,11 +2190,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." @@ -2362,12 +2343,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 "" @@ -2393,11 +2369,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 @@ -3575,7 +3546,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 "" @@ -3968,7 +3939,22 @@ msgstr "" msgid "To" msgstr "" -#~ #: lib/mv_web/live/group_live/show.ex -#~ #, elixir-autogen, elixir-format, fuzzy -#~ msgid "No members selected." -#~ 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 "" diff --git a/test/mv_web/components/bulk_actions_dropdown_test.exs b/test/mv_web/components/bulk_actions_dropdown_test.exs new file mode 100644 index 0000000..42dd55a --- /dev/null +++ b/test/mv_web/components/bulk_actions_dropdown_test.exs @@ -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 and