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