diff --git a/.gitignore b/.gitignore index b37fa85..14620df 100644 --- a/.gitignore +++ b/.gitignore @@ -34,7 +34,6 @@ mv-*.tar # In case you use Node.js/npm, you want to ignore these. npm-debug.log /assets/node_modules/ -/node_modules/ .cursor diff --git a/.tool-versions b/.tool-versions index e815bde..e72ed5f 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1,4 +1,3 @@ elixir 1.18.3-otp-27 erlang 27.3.4 -just 1.51.0 -nodejs 26.2.0 +just 1.50.0 diff --git a/CHANGELOG.md b/CHANGELOG.md index adbe7e7..74d015d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,8 +17,6 @@ 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 diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 98d4053..37f9552 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -33,7 +33,7 @@ services: restart: unless-stopped db-prod: - image: postgres:18.4-alpine + image: postgres:18.3-alpine container_name: mv-prod-db environment: POSTGRES_USER: postgres diff --git a/docker-compose.yml b/docker-compose.yml index cbd2e9e..01a0bd2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,7 +4,7 @@ networks: services: db: - image: postgres:18.4-alpine + image: postgres:18.3-alpine environment: POSTGRES_USER: postgres POSTGRES_PASSWORD: postgres @@ -25,7 +25,7 @@ services: rauthy: container_name: rauthy-dev - image: ghcr.io/sebadob/rauthy:0.35.2 + image: ghcr.io/sebadob/rauthy:0.35.1 environment: - LOCAL_TEST=true - SMTP_URL=mailcrab diff --git a/lib/mv/constants.ex b/lib/mv/constants.ex index 657aa9b..4d09c89 100644 --- a/lib/mv/constants.ex +++ b/lib/mv/constants.ex @@ -40,8 +40,6 @@ defmodule Mv.Constants do @max_boolean_filters 50 - @max_mailto_bulk_recipients 50 - @max_uuid_length 36 @email_validator_checks [:html_input, :pow] @@ -175,21 +173,6 @@ 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 deleted file mode 100644 index d0b6172..0000000 --- a/lib/mv_web/components/bulk_actions_dropdown.ex +++ /dev/null @@ -1,243 +0,0 @@ -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-70" - menu_align="left" - button_class="btn-secondary gap-2" - testid="bulk-actions-dropdown" - button_testid="bulk-actions-button" - menu_testid="bulk-actions-menu" - > - <:trigger_badge> - <.badge variant={@scope_variant} size="sm" data-testid="bulk-actions-scope-badge"> - {@scope_label} - - -
  • - <.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/core_components.ex b/lib/mv_web/components/core_components.ex index 13c69a8..465d41a 100644 --- a/lib/mv_web/components/core_components.ex +++ b/lib/mv_web/components/core_components.ex @@ -464,9 +464,6 @@ 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" @@ -501,8 +498,6 @@ defmodule MvWeb.CoreComponents do <.icon name={@icon} /> <% end %> {@button_label} - {render_slot(@trigger_badge)} - <.icon name="hero-chevron-down" class="size-4" />