diff --git a/.opencode/screenshots/01_mitglieder.png b/.opencode/screenshots/01_mitglieder.png new file mode 100644 index 0000000..7cf25af Binary files /dev/null and b/.opencode/screenshots/01_mitglieder.png differ diff --git a/.opencode/screenshots/02_statistik.png b/.opencode/screenshots/02_statistik.png new file mode 100644 index 0000000..675c036 Binary files /dev/null and b/.opencode/screenshots/02_statistik.png differ diff --git a/.opencode/screenshots/03_beitraege.png b/.opencode/screenshots/03_beitraege.png new file mode 100644 index 0000000..5918953 Binary files /dev/null and b/.opencode/screenshots/03_beitraege.png differ diff --git a/.opencode/screenshots/04_aufnahmeantraege.png b/.opencode/screenshots/04_aufnahmeantraege.png new file mode 100644 index 0000000..13bb316 Binary files /dev/null and b/.opencode/screenshots/04_aufnahmeantraege.png differ diff --git a/.tool-versions b/.tool-versions index e72ed5f..cf63238 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1,3 +1,3 @@ elixir 1.18.3-otp-27 erlang 27.3.4 -just 1.50.0 +just 1.51.0 diff --git a/CHANGELOG.md b/CHANGELOG.md index edb53f9..adbe7e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,11 +8,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added +- **GDPR/DSGVO join-form description** – Custom fields can carry a "join form description" that is shown as the field's label on the public join form, with clickable external links (whole URLs and Markdown `[text](url)`). Useful for presenting a GDPR confirmation with a link to an externally hosted privacy declaration before sign-up. +- **Join-form description tooltip in member details** – Custom fields that have a join-form description show an info tooltip (prefixed "Beitrittsformular:") on their label in the member detail view. +- **Editable join-form description** – Admins can set a field's join-form description in the custom-field settings, with an inline hint about the supported link syntax. - **CSV import – groups column** – Members can be assigned to groups during CSV import via a `Groups`/`Gruppen` column; group names that do not exist yet are created automatically, and re-importing the same file does not create duplicate groups. - **CSV import – membership fee type column** – A `Fee Type`/`Beitragsart` column assigns each member's membership fee type; an unknown name falls back to the default fee type and is flagged in the preview with a link to create it. - **CSV import – mapping preview** – After uploading a file, a preview shows how every column maps (with sample rows and warnings for ignored or unknown columns) and the import only starts once you confirm. - **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 - **CSV date round-trip** – Date custom-field values are now exported as ISO-8601 (`YYYY-MM-DD`), so an exported CSV can be re-imported without date-parsing errors. - **CSV import – fee-status columns ignored** – Columns such as `Bezahlstatus` / `Membership Fee Status` are always ignored on import and never stored as a custom-field value, even when a custom field of the same name exists. diff --git a/CODE_GUIDELINES.md b/CODE_GUIDELINES.md index 2b378ef..ccd16f4 100644 --- a/CODE_GUIDELINES.md +++ b/CODE_GUIDELINES.md @@ -1363,6 +1363,8 @@ mix gettext.merge priv/gettext --on-obsolete=mark_as_obsolete ### 3.13 Task Runner: Just +The `Justfile` prepends `~/.asdf/shims`, `~/.asdf/bin`, and `~/.asdf` to `PATH` for all recipes (`set export := true`), so `mix` / `elixir` resolve from `.tool-versions` without shell init. The caller's `PATH` is kept (e.g. Homebrew `asdf`, Docker). Run `asdf install` once per machine; no extra `source` is required for `just run`. + **Common Commands:** ```bash diff --git a/Justfile b/Justfile index d08cef8..9b0be65 100644 --- a/Justfile +++ b/Justfile @@ -1,11 +1,11 @@ set dotenv-load := true set export := true -# Non-interactive shells do not source .bashrc, -# PATH includes asdf shims so that mix / elixir / iex resolve without per-shell -# `source ~/.asdf/asdf.sh`. Recipes inherit this via `set export := true`. -home := env_var('HOME') -PATH := home + "/.asdf/shims:" + home + "/.asdf:" + home + "/.local/bin:/usr/local/bin:/usr/bin:/bin" +# Prepend asdf paths so recipes work without sourcing ~/.asdf/asdf.sh in the shell. +# Caller PATH is preserved (Homebrew asdf, docker CLI, etc.). See CODE_GUIDELINES §3.13. +home := env_var("HOME") +asdf_paths := home + "/.asdf/shims:" + home + "/.asdf/bin:" + home + "/.asdf:" +PATH := asdf_paths + env_var("PATH") MIX_QUIET := "1" diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 37f9552..98d4053 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -33,7 +33,7 @@ services: restart: unless-stopped db-prod: - image: postgres:18.3-alpine + image: postgres:18.4-alpine container_name: mv-prod-db environment: POSTGRES_USER: postgres diff --git a/docker-compose.yml b/docker-compose.yml index 01a0bd2..cbd2e9e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,7 +4,7 @@ networks: services: db: - image: postgres:18.3-alpine + image: postgres:18.4-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.1 + image: ghcr.io/sebadob/rauthy:0.35.2 environment: - LOCAL_TEST=true - SMTP_URL=mailcrab diff --git a/lib/membership/custom_field.ex b/lib/membership/custom_field.ex index ef6c79a..5f4dd0e 100644 --- a/lib/membership/custom_field.ex +++ b/lib/membership/custom_field.ex @@ -12,6 +12,8 @@ defmodule Mv.Membership.CustomField do - `slug` - URL-friendly, immutable identifier automatically generated from name (e.g., "phone-mobile") - `value_type` - Data type constraint (`:string`, `:integer`, `:boolean`, `:date`, `:email`). Immutable after creation. - `description` - Optional human-readable description + - `join_description` - Optional label shown for this field on the public join form + (e.g., a GDPR confirmation text); supports inline external links. Falls back to `name` when nil. - `required` - If true, all members must have this custom field (future feature) - `show_in_overview` - If true, this custom field will be displayed in the member overview table and can be sorted @@ -61,7 +63,14 @@ defmodule Mv.Membership.CustomField do end actions do - default_accept [:name, :value_type, :description, :required, :show_in_overview] + default_accept [ + :name, + :value_type, + :description, + :join_description, + :required, + :show_in_overview + ] read :read do primary? true @@ -69,13 +78,13 @@ defmodule Mv.Membership.CustomField do end create :create do - accept [:name, :value_type, :description, :required, :show_in_overview] + accept [:name, :value_type, :description, :join_description, :required, :show_in_overview] change Mv.Membership.Changes.GenerateSlug validate string_length(:slug, min: 1) end update :update do - accept [:name, :description, :required, :show_in_overview] + accept [:name, :description, :join_description, :required, :show_in_overview] require_atomic? false validate fn changeset, _context -> @@ -139,6 +148,15 @@ defmodule Mv.Membership.CustomField do trim?: true ] + attribute :join_description, :string, + allow_nil?: true, + public?: true, + description: "Label shown for this field on the public join form; supports external links", + constraints: [ + max_length: 1000, + trim?: true + ] + attribute :required, :boolean, default: false, allow_nil?: false 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/core_components.ex b/lib/mv_web/components/core_components.ex index 465d41a..13c69a8 100644 --- a/lib/mv_web/components/core_components.ex +++ b/lib/mv_web/components/core_components.ex @@ -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 %> {@button_label} + {render_slot(@trigger_badge)} + <.icon name="hero-chevron-down" class="size-4" />