Merge branch 'main' into renovate/mix-dependencies
This commit is contained in:
commit
72cf85e5cb
44 changed files with 1958 additions and 379 deletions
BIN
.opencode/screenshots/01_mitglieder.png
Normal file
BIN
.opencode/screenshots/01_mitglieder.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 233 KiB |
BIN
.opencode/screenshots/02_statistik.png
Normal file
BIN
.opencode/screenshots/02_statistik.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 122 KiB |
BIN
.opencode/screenshots/03_beitraege.png
Normal file
BIN
.opencode/screenshots/03_beitraege.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 265 KiB |
BIN
.opencode/screenshots/04_aufnahmeantraege.png
Normal file
BIN
.opencode/screenshots/04_aufnahmeantraege.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 136 KiB |
|
|
@ -1,3 +1,3 @@
|
|||
elixir 1.18.3-otp-27
|
||||
erlang 27.3.4
|
||||
just 1.50.0
|
||||
just 1.51.0
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
10
Justfile
10
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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -40,6 +40,8 @@ defmodule Mv.Constants do
|
|||
|
||||
@max_boolean_filters 50
|
||||
|
||||
@max_mailto_bulk_recipients 50
|
||||
|
||||
@max_uuid_length 36
|
||||
|
||||
@email_validator_checks [:html_input, :pow]
|
||||
|
|
@ -173,6 +175,21 @@ defmodule Mv.Constants do
|
|||
"""
|
||||
def max_boolean_filters, do: @max_boolean_filters
|
||||
|
||||
@doc """
|
||||
Returns the maximum number of mailto recipients before the bulk "open in email
|
||||
program" action is disabled.
|
||||
|
||||
The mailto link carries every recipient in its BCC; browsers cannot reliably
|
||||
hand a too-long mailto URI to the mail program. At or above this count the
|
||||
action is disabled in the UI (Copy and Export have no such limit).
|
||||
|
||||
## Examples
|
||||
|
||||
iex> Mv.Constants.max_mailto_bulk_recipients()
|
||||
50
|
||||
"""
|
||||
def max_mailto_bulk_recipients, do: @max_mailto_bulk_recipients
|
||||
|
||||
@doc """
|
||||
Returns the maximum length of a UUID string (36 characters including hyphens).
|
||||
|
||||
|
|
|
|||
243
lib/mv_web/components/bulk_actions_dropdown.ex
Normal file
243
lib/mv_web/components/bulk_actions_dropdown.ex
Normal file
|
|
@ -0,0 +1,243 @@
|
|||
defmodule MvWeb.Components.BulkActionsDropdown do
|
||||
@moduledoc """
|
||||
Single "Aktionen" dropdown bundling the four member bulk actions, flattened to
|
||||
one level: open in email program (mailto), copy email addresses, export to CSV,
|
||||
export to PDF.
|
||||
|
||||
It keeps the CSRF-protected `<form>` POST export items unchanged (CSV/PDF) and
|
||||
adds the mailto and copy items that previously lived as standalone header
|
||||
buttons next to a separate export dropdown.
|
||||
|
||||
## Scope and trigger badge
|
||||
|
||||
The trigger reads `Aktionen` followed by a scope badge: an emphasized
|
||||
(`primary`) count `N` when `N` members are selected, and a muted (`neutral`)
|
||||
badge otherwise — `gefiltert` when a search term or filter narrows the list,
|
||||
`alle` when nothing is selected and no search/filter is active. Only an actual
|
||||
selection is emphasized. The badge sits inside the shared `dropdown_menu/1`
|
||||
trigger via its `trigger_badge` slot, matching the member-filter dropdown's
|
||||
count badge. The `scope`, `selected_count`, `mailto_bcc`, `recipient_count`
|
||||
and `mailto_disabled?` are computed by the parent LiveView and passed in.
|
||||
|
||||
## Recipient handling (mailto / copy)
|
||||
|
||||
The parent already excludes members without an email when building
|
||||
`mailto_bcc` and `recipient_count` (defensive filter preserved verbatim from
|
||||
the previous behaviour). Export, by contrast, still includes every member in
|
||||
scope regardless of email — its payload is unchanged.
|
||||
|
||||
## Mailto recipient cap
|
||||
|
||||
A mailto URI carries every recipient in its BCC; browsers cannot reliably hand
|
||||
a very long mailto over to the mail program. When `mailto_disabled?` is true
|
||||
(recipient count at or above `Mv.Constants.max_mailto_bulk_recipients/0`) the
|
||||
mailto item is rendered disabled (`aria-disabled`, `tabindex="-1"`, href
|
||||
dropped) with an explanatory tooltip. Copy and Export have no such cap.
|
||||
|
||||
## Event routing
|
||||
|
||||
`dropdown_menu/1` sends `toggle_dropdown`/`close_dropdown` to `@myself`, so the
|
||||
component owns its own `:open` state. The copy item carries an *un-targeted*
|
||||
`phx-click="copy_emails"`, which therefore reaches the parent LiveView's
|
||||
`handle_event/3` (which keeps access to `@members`), plus the
|
||||
`CopyToClipboard` hook.
|
||||
"""
|
||||
use MvWeb, :live_component
|
||||
use Gettext, backend: MvWeb.Gettext
|
||||
|
||||
# Same focus ring as CoreComponents button/dropdown (WCAG 2.4.7)
|
||||
defp dropdown_item_class do
|
||||
focus =
|
||||
MvWeb.CoreComponents.button_focus_classes()
|
||||
|> Kernel.++(["focus-visible:ring-inset"])
|
||||
|> Enum.join(" ")
|
||||
|
||||
"flex items-center gap-2 px-2 py-1 rounded cursor-pointer hover:bg-base-200 w-full text-left whitespace-nowrap #{focus}"
|
||||
end
|
||||
|
||||
@impl true
|
||||
def mount(socket) do
|
||||
{:ok, assign(socket, :open, false)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def update(assigns, socket) do
|
||||
socket =
|
||||
socket
|
||||
|> assign(:id, assigns.id)
|
||||
|> assign(:export_payload_json, assigns[:export_payload_json] || "")
|
||||
|> assign(:selected_count, assigns[:selected_count] || 0)
|
||||
|> assign(:scope, assigns[:scope] || :all)
|
||||
|> assign(:mailto_bcc, assigns[:mailto_bcc] || "")
|
||||
|> assign(:recipient_count, assigns[:recipient_count] || 0)
|
||||
|> assign(:mailto_disabled?, assigns[:mailto_disabled?] || false)
|
||||
|
||||
# The parent never sets :open (the component owns it via toggle/close).
|
||||
# Honouring an explicit :open assign keeps the component renderable in
|
||||
# isolation (render_component/2) for structural tests.
|
||||
socket =
|
||||
case Map.fetch(assigns, :open) do
|
||||
{:ok, open} -> assign(socket, :open, open)
|
||||
:error -> socket
|
||||
end
|
||||
|
||||
{:ok, socket}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
assigns =
|
||||
assigns
|
||||
|> assign(:scope_label, scope_label(assigns))
|
||||
|> assign(:scope_variant, scope_variant(assigns))
|
||||
|
||||
~H"""
|
||||
<div id={@id} data-testid="bulk-actions-dropdown" class="flex-auto flex-wrap">
|
||||
<.dropdown_menu
|
||||
id={"#{@id}-menu"}
|
||||
button_label={gettext("Actions")}
|
||||
icon="hero-bolt"
|
||||
open={@open}
|
||||
phx_target={@myself}
|
||||
menu_width="w-64"
|
||||
menu_align="left"
|
||||
button_class="btn-secondary gap-2"
|
||||
testid="bulk-actions-dropdown"
|
||||
button_testid="bulk-actions-button"
|
||||
menu_testid="bulk-actions-menu"
|
||||
>
|
||||
<:trigger_badge>
|
||||
<.badge variant={@scope_variant} size="sm" data-testid="bulk-actions-scope-badge">
|
||||
{@scope_label}
|
||||
</.badge>
|
||||
</:trigger_badge>
|
||||
<li role="none">
|
||||
<.mailto_item mailto_bcc={@mailto_bcc} disabled={@mailto_disabled?} />
|
||||
</li>
|
||||
<li role="none">
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
id="bulk-actions-copy"
|
||||
phx-hook="CopyToClipboard"
|
||||
phx-click="copy_emails"
|
||||
class={dropdown_item_class()}
|
||||
aria-label={gettext("Copy email addresses")}
|
||||
data-testid="bulk-actions-copy"
|
||||
>
|
||||
<.icon name="hero-clipboard-document" class="h-4 w-4" />
|
||||
<span>{gettext("Copy email addresses")}</span>
|
||||
</button>
|
||||
</li>
|
||||
<li role="none">
|
||||
<form method="post" action={~p"/members/export.csv"} target="_blank" class="w-full">
|
||||
<input type="hidden" name="_csrf_token" value={Plug.CSRFProtection.get_csrf_token()} />
|
||||
<input type="hidden" name="payload" value={@export_payload_json} />
|
||||
<button
|
||||
type="submit"
|
||||
role="menuitem"
|
||||
class={dropdown_item_class()}
|
||||
aria-label={gettext("Export members to CSV")}
|
||||
data-testid="export-csv-link"
|
||||
>
|
||||
<.icon name="hero-document-arrow-down" class="h-4 w-4" />
|
||||
<span>{gettext("Export to CSV")}</span>
|
||||
</button>
|
||||
</form>
|
||||
</li>
|
||||
<li role="none">
|
||||
<form method="post" action={~p"/members/export.pdf"} target="_blank" class="w-full">
|
||||
<input type="hidden" name="_csrf_token" value={Plug.CSRFProtection.get_csrf_token()} />
|
||||
<input type="hidden" name="payload" value={@export_payload_json} />
|
||||
<button
|
||||
type="submit"
|
||||
role="menuitem"
|
||||
class={dropdown_item_class()}
|
||||
aria-label={gettext("Export members to PDF")}
|
||||
data-testid="export-pdf-link"
|
||||
>
|
||||
<.icon name="hero-document-text" class="h-4 w-4" />
|
||||
<span>{gettext("Export to PDF")}</span>
|
||||
</button>
|
||||
</form>
|
||||
</li>
|
||||
</.dropdown_menu>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
# The mailto item is an anchor menu item. When over the recipient cap it is
|
||||
# rendered disabled following the same a11y pattern as a disabled CoreComponents
|
||||
# link button (href dropped, tabindex=-1, aria-disabled=true) and exposes the
|
||||
# explanatory tooltip via title.
|
||||
attr :mailto_bcc, :string, required: true
|
||||
attr :disabled, :boolean, required: true
|
||||
|
||||
defp mailto_item(%{disabled: true} = assigns) do
|
||||
assigns = assign(assigns, :item_class, dropdown_item_class())
|
||||
|
||||
~H"""
|
||||
<a
|
||||
role="menuitem"
|
||||
tabindex="-1"
|
||||
aria-disabled="true"
|
||||
title={over_threshold_tooltip()}
|
||||
class={[@item_class, "opacity-50 pointer-events-none"]}
|
||||
aria-label={gettext("Open in email program")}
|
||||
data-testid="bulk-actions-mailto"
|
||||
>
|
||||
<.icon name="hero-envelope" class="h-4 w-4" />
|
||||
<span>{gettext("Open in email program")}</span>
|
||||
</a>
|
||||
"""
|
||||
end
|
||||
|
||||
defp mailto_item(%{disabled: false} = assigns) do
|
||||
assigns = assign(assigns, :item_class, dropdown_item_class())
|
||||
|
||||
~H"""
|
||||
<a
|
||||
role="menuitem"
|
||||
tabindex="0"
|
||||
href={"mailto:?bcc=" <> @mailto_bcc}
|
||||
class={@item_class}
|
||||
aria-label={gettext("Open in email program")}
|
||||
data-testid="bulk-actions-mailto"
|
||||
>
|
||||
<.icon name="hero-envelope" class="h-4 w-4" />
|
||||
<span>{gettext("Open in email program")}</span>
|
||||
</a>
|
||||
"""
|
||||
end
|
||||
|
||||
defp over_threshold_tooltip do
|
||||
gettext("Too many recipients for this function. Copy the addresses or export the list.")
|
||||
end
|
||||
|
||||
# The trigger scope is shown as a badge after the "Aktionen" label. Only an
|
||||
# actual selection is emphasized (primary); both the "filtered" and "all"
|
||||
# scopes are muted (neutral), since neither means members are selected.
|
||||
defp scope_label(assigns) do
|
||||
case assigns.scope do
|
||||
:selection -> to_string(assigns.selected_count)
|
||||
:filtered -> gettext("filtered")
|
||||
_ -> gettext("all")
|
||||
end
|
||||
end
|
||||
|
||||
defp scope_variant(assigns) do
|
||||
case assigns.scope do
|
||||
:selection -> "primary"
|
||||
_ -> "neutral"
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("toggle_dropdown", _params, socket) do
|
||||
{:noreply, assign(socket, :open, !socket.assigns.open)}
|
||||
end
|
||||
|
||||
def handle_event("close_dropdown", _params, socket) do
|
||||
{:noreply, assign(socket, :open, false)}
|
||||
end
|
||||
end
|
||||
|
|
@ -464,6 +464,9 @@ defmodule MvWeb.CoreComponents do
|
|||
|
||||
slot :inner_block, doc: "Custom content for the dropdown menu (e.g., forms)"
|
||||
|
||||
slot :trigger_badge,
|
||||
doc: "Optional badge rendered in the trigger after the label (e.g. a scope badge)"
|
||||
|
||||
def dropdown_menu(assigns) do
|
||||
menu_testid = assigns.menu_testid || "#{assigns.testid}-menu"
|
||||
|
||||
|
|
@ -498,6 +501,8 @@ defmodule MvWeb.CoreComponents do
|
|||
<.icon name={@icon} />
|
||||
<% end %>
|
||||
<span>{@button_label}</span>
|
||||
{render_slot(@trigger_badge)}
|
||||
<.icon name="hero-chevron-down" class="size-4" />
|
||||
</button>
|
||||
|
||||
<ul
|
||||
|
|
|
|||
|
|
@ -1,110 +0,0 @@
|
|||
defmodule MvWeb.Components.ExportDropdown do
|
||||
@moduledoc """
|
||||
Export dropdown component for member export (CSV/PDF).
|
||||
|
||||
Provides an accessible dropdown menu with CSV and PDF export options.
|
||||
Uses the same export payload as the previous single-button export.
|
||||
"""
|
||||
use MvWeb, :live_component
|
||||
use Gettext, backend: MvWeb.Gettext
|
||||
|
||||
# Same focus ring as CoreComponents button/dropdown (WCAG 2.4.7)
|
||||
defp dropdown_item_class do
|
||||
focus =
|
||||
MvWeb.CoreComponents.button_focus_classes()
|
||||
|> Kernel.++(["focus-visible:ring-inset"])
|
||||
|> Enum.join(" ")
|
||||
|
||||
"flex items-center gap-2 px-2 py-1 rounded cursor-pointer hover:bg-base-200 w-full text-left #{focus}"
|
||||
end
|
||||
|
||||
@impl true
|
||||
def mount(socket) do
|
||||
{:ok, assign(socket, :open, false)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def update(assigns, socket) do
|
||||
socket =
|
||||
socket
|
||||
|> assign(:id, assigns.id)
|
||||
|> assign(:export_payload_json, assigns[:export_payload_json] || "")
|
||||
|> assign(:selected_count, assigns[:selected_count] || 0)
|
||||
|
||||
{:ok, socket}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
button_label =
|
||||
gettext("Export") <>
|
||||
" (" <>
|
||||
if(assigns.selected_count == 0,
|
||||
do: gettext("all"),
|
||||
else: to_string(assigns.selected_count)
|
||||
) <>
|
||||
")"
|
||||
|
||||
assigns = assign(assigns, :button_label, button_label)
|
||||
|
||||
~H"""
|
||||
<div id={@id} data-testid="export-dropdown" class="flex-auto flex-wrap">
|
||||
<.dropdown_menu
|
||||
id={"#{@id}-menu"}
|
||||
button_label={@button_label}
|
||||
icon="hero-arrow-down-tray"
|
||||
open={@open}
|
||||
phx_target={@myself}
|
||||
menu_width="w-48"
|
||||
menu_align="left"
|
||||
button_class="btn-secondary gap-2"
|
||||
testid="export-dropdown"
|
||||
button_testid="export-dropdown-button"
|
||||
menu_testid="export-dropdown-menu"
|
||||
>
|
||||
<li role="none">
|
||||
<form method="post" action={~p"/members/export.csv"} target="_blank" class="w-full">
|
||||
<input type="hidden" name="_csrf_token" value={Plug.CSRFProtection.get_csrf_token()} />
|
||||
<input type="hidden" name="payload" value={@export_payload_json} />
|
||||
<button
|
||||
type="submit"
|
||||
role="menuitem"
|
||||
class={dropdown_item_class()}
|
||||
aria-label={gettext("Export members to CSV")}
|
||||
data-testid="export-csv-link"
|
||||
>
|
||||
<.icon name="hero-document-arrow-down" class="h-4 w-4" />
|
||||
<span>{gettext("CSV")}</span>
|
||||
</button>
|
||||
</form>
|
||||
</li>
|
||||
<li role="none">
|
||||
<form method="post" action={~p"/members/export.pdf"} target="_blank" class="w-full">
|
||||
<input type="hidden" name="_csrf_token" value={Plug.CSRFProtection.get_csrf_token()} />
|
||||
<input type="hidden" name="payload" value={@export_payload_json} />
|
||||
<button
|
||||
type="submit"
|
||||
role="menuitem"
|
||||
class={dropdown_item_class()}
|
||||
aria-label={gettext("Export members to PDF")}
|
||||
data-testid="export-pdf-link"
|
||||
>
|
||||
<.icon name="hero-document-text" class="h-4 w-4" />
|
||||
<span>{gettext("PDF")}</span>
|
||||
</button>
|
||||
</form>
|
||||
</li>
|
||||
</.dropdown_menu>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("toggle_dropdown", _params, socket) do
|
||||
{:noreply, assign(socket, :open, !socket.assigns.open)}
|
||||
end
|
||||
|
||||
def handle_event("close_dropdown", _params, socket) do
|
||||
{:noreply, assign(socket, :open, false)}
|
||||
end
|
||||
end
|
||||
70
lib/mv_web/helpers/join_description_renderer.ex
Normal file
70
lib/mv_web/helpers/join_description_renderer.ex
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
defmodule MvWeb.Helpers.JoinDescriptionRenderer do
|
||||
@moduledoc """
|
||||
Renders a custom field's `join_description` into Phoenix-safe HTML for the
|
||||
public join form.
|
||||
|
||||
The renderer auto-links two patterns into `<a href="...">` tags:
|
||||
|
||||
- Markdown links of the form `[text](url)` (processed first)
|
||||
- bare `http(s)://` URLs in the remaining text
|
||||
|
||||
All other content is HTML-escaped: only `<a href="...">` tags are ever
|
||||
emitted, so arbitrary HTML in the input is rendered as inert text. This is a
|
||||
defense-in-depth measure — `join_description` is admin-set content, never
|
||||
end-user input — but the renderer must not become a vector for injecting
|
||||
arbitrary markup.
|
||||
|
||||
Markdown links are matched before bare URLs and their matched region is
|
||||
consumed, so a Markdown link whose URL also looks like a bare URL is linked
|
||||
exactly once (no nested anchors).
|
||||
"""
|
||||
|
||||
@markdown_link ~r/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/
|
||||
@bare_url ~r/(https?:\/\/[^\s<]+)/
|
||||
@bare_url_anchored ~r/\A(https?:\/\/[^\s<]+)\z/
|
||||
|
||||
@doc """
|
||||
Converts `value` to a Phoenix-safe HTML iolist.
|
||||
|
||||
Returns `{:safe, ""}` for `nil`. For a string, returns `{:safe, iolist}` with
|
||||
links rendered and all other text HTML-escaped.
|
||||
"""
|
||||
@spec render(String.t() | nil) :: Phoenix.HTML.safe()
|
||||
def render(nil), do: {:safe, ""}
|
||||
|
||||
def render(value) when is_binary(value) do
|
||||
{:safe, render_segments(value)}
|
||||
end
|
||||
|
||||
# Split on Markdown links first; for each non-Markdown segment, link bare URLs;
|
||||
# everything that is not a link is HTML-escaped.
|
||||
defp render_segments(text) do
|
||||
Regex.split(@markdown_link, text, include_captures: true)
|
||||
|> Enum.map(&render_markdown_or_plain/1)
|
||||
end
|
||||
|
||||
defp render_markdown_or_plain(segment) do
|
||||
case Regex.run(@markdown_link, segment) do
|
||||
[^segment, label, url] -> anchor(url, label)
|
||||
_ -> render_plain(segment)
|
||||
end
|
||||
end
|
||||
|
||||
# Auto-link bare URLs in a plain-text segment, escaping all surrounding text.
|
||||
defp render_plain(segment) do
|
||||
Regex.split(@bare_url, segment, include_captures: true)
|
||||
|> Enum.map(fn part ->
|
||||
if Regex.match?(@bare_url_anchored, part) do
|
||||
anchor(part, part)
|
||||
else
|
||||
escape(part)
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
defp anchor(url, label) do
|
||||
["<a href=\"", escape(url), "\" class=\"link link-primary\">", escape(label), "</a>"]
|
||||
end
|
||||
|
||||
defp escape(text), do: Phoenix.HTML.html_escape(text) |> Phoenix.HTML.safe_to_string()
|
||||
end
|
||||
|
|
@ -156,6 +156,7 @@ defmodule MvWeb.Components.MemberFilterComponent do
|
|||
>
|
||||
{@member_count}
|
||||
</.badge>
|
||||
<.icon name="hero-chevron-down" class="size-4" />
|
||||
</.button>
|
||||
|
||||
<!--
|
||||
|
|
|
|||
|
|
@ -91,6 +91,45 @@ defmodule MvWeb.CustomFieldLive.FormComponent do
|
|||
<% end %>
|
||||
|
||||
<.input field={@form[:description]} type="text" label={gettext("Description")} />
|
||||
|
||||
<fieldset class="mb-2 fieldset">
|
||||
<label>
|
||||
<span class="mb-1 label flex items-center gap-2">
|
||||
{gettext("Description for join form")}
|
||||
<.tooltip
|
||||
content={
|
||||
gettext(
|
||||
"You can add links: full addresses (https://…) or as [link text](https://…)."
|
||||
)
|
||||
}
|
||||
position="right"
|
||||
>
|
||||
<span
|
||||
data-testid="join-description-link-hint"
|
||||
aria-label={
|
||||
gettext(
|
||||
"You can add links: full addresses (https://…) or as [link text](https://…)."
|
||||
)
|
||||
}
|
||||
>
|
||||
<.icon
|
||||
name="hero-information-circle"
|
||||
class="w-4 h-4 text-base-content/60 cursor-help"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</span>
|
||||
</.tooltip>
|
||||
</span>
|
||||
<input
|
||||
type="text"
|
||||
name={@form[:join_description].name}
|
||||
id={@form[:join_description].id}
|
||||
value={Phoenix.HTML.Form.normalize_value("text", @form[:join_description].value)}
|
||||
class="w-full input"
|
||||
/>
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
<.input field={@form[:required]} type="checkbox" label={gettext("Required")} />
|
||||
<.input
|
||||
field={@form[:show_in_overview]}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ defmodule MvWeb.JoinLive do
|
|||
alias Ash.Resource.Info
|
||||
alias Mv.Membership
|
||||
alias Mv.Membership.CustomFieldLookup
|
||||
alias MvWeb.Helpers.JoinDescriptionRenderer
|
||||
alias MvWeb.JoinRateLimit
|
||||
alias MvWeb.Translations.MemberFields
|
||||
|
||||
|
|
@ -96,14 +97,20 @@ defmodule MvWeb.JoinLive do
|
|||
class="checkbox checkbox-sm"
|
||||
/>
|
||||
<span class="label-text">
|
||||
{field.label}<span :if={field.required} aria-hidden="true"> *</span>
|
||||
{render_field_label(field)}<span
|
||||
:if={field.required}
|
||||
aria-hidden="true"
|
||||
> *</span>
|
||||
</span>
|
||||
</label>
|
||||
<% else %>
|
||||
<div>
|
||||
<label for={"join-field-#{field.id}"} class="label">
|
||||
<span class="label-text">
|
||||
{field.label}<span :if={field.required} aria-hidden="true"> *</span>
|
||||
{render_field_label(field)}<span
|
||||
:if={field.required}
|
||||
aria-hidden="true"
|
||||
> *</span>
|
||||
</span>
|
||||
</label>
|
||||
<input
|
||||
|
|
@ -237,6 +244,17 @@ defmodule MvWeb.JoinLive do
|
|||
|> assign(:form, to_form(params, as: "join"))}
|
||||
end
|
||||
|
||||
# Renders a join field's label. When a custom field has a join_description it is
|
||||
# rendered with auto-linked URLs/Markdown; otherwise the plain field label is used.
|
||||
# Safe: join_description is admin-set settings content, never end-user input, and
|
||||
# JoinDescriptionRenderer escapes all non-link text (only emits <a href> tags).
|
||||
defp render_field_label(%{join_description: join_description})
|
||||
when is_binary(join_description) do
|
||||
JoinDescriptionRenderer.render(join_description)
|
||||
end
|
||||
|
||||
defp render_field_label(%{label: label}), do: label
|
||||
|
||||
defp build_join_fields_with_labels(allowlist) do
|
||||
member_field_strings = Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)
|
||||
custom_field_by_id = custom_field_map(allowlist, member_field_strings)
|
||||
|
|
@ -249,20 +267,36 @@ defmodule MvWeb.JoinLive do
|
|||
defp build_join_field(id, required, member_field_strings, custom_field_by_id) do
|
||||
if id in member_field_strings do
|
||||
label = MemberFields.label(String.to_existing_atom(id))
|
||||
%{id: id, label: label, required: required, input_type: member_field_input_type(id)}
|
||||
|
||||
%{
|
||||
id: id,
|
||||
label: label,
|
||||
required: required,
|
||||
input_type: member_field_input_type(id),
|
||||
join_description: nil
|
||||
}
|
||||
else
|
||||
custom_field = Map.get(custom_field_by_id, id)
|
||||
label = if custom_field, do: custom_field.name, else: gettext("Field")
|
||||
input_type = custom_field_input_type(custom_field && custom_field.value_type)
|
||||
|
||||
%{id: id, label: label, required: required, input_type: input_type}
|
||||
%{
|
||||
id: id,
|
||||
label: label,
|
||||
required: required,
|
||||
input_type: input_type,
|
||||
join_description: custom_field && custom_field.join_description
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
defp custom_field_map(allowlist, _member_field_strings) do
|
||||
allowlist
|
||||
|> Enum.map(& &1.id)
|
||||
|> CustomFieldLookup.fetch_map_by_ids(authorize?: false, select: [:id, :name, :value_type])
|
||||
|> CustomFieldLookup.fetch_map_by_ids(
|
||||
authorize?: false,
|
||||
select: [:id, :name, :value_type, :join_description]
|
||||
)
|
||||
end
|
||||
|
||||
defp initial_form_params(join_fields) do
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ defmodule MvWeb.MemberLive.Index do
|
|||
## Events
|
||||
- `select_member` - Toggle individual member selection
|
||||
- `select_all` - Toggle selection of all visible members
|
||||
- `copy_emails` - Copy email addresses of selected members to clipboard
|
||||
- `copy_emails` - Copy email addresses of the selected members, or of all/filtered members when nothing is selected
|
||||
|
||||
## Implementation Notes
|
||||
- Search uses PostgreSQL full-text search (plainto_tsquery)
|
||||
|
|
@ -250,41 +250,42 @@ defmodule MvWeb.MemberLive.Index do
|
|||
|
||||
@impl true
|
||||
def handle_event("copy_emails", _params, socket) do
|
||||
members = socket.assigns.members
|
||||
selected_ids = socket.assigns.selected_members
|
||||
any_selected? = Enum.any?(members, &MapSet.member?(selected_ids, &1.id))
|
||||
|
||||
# Filter members that are in the selection and have email addresses
|
||||
formatted_emails = format_selected_member_emails(socket.assigns.members, selected_ids)
|
||||
# Recipients follow the current scope: the selection when present, otherwise
|
||||
# every member in the (filtered) list. Members without an email are excluded
|
||||
# in both cases (unchanged missing-email handling). With no selection we no
|
||||
# longer hard-stop with "No members selected" — we act on the scope; the
|
||||
# empty-recipient feedback below is preserved.
|
||||
formatted_emails = scope_member_emails(members, selected_ids, any_selected?)
|
||||
email_count = length(formatted_emails)
|
||||
|
||||
cond do
|
||||
MapSet.size(selected_ids) == 0 ->
|
||||
{:noreply, put_flash(socket, :error, gettext("No members selected"))}
|
||||
if email_count == 0 do
|
||||
{:noreply, put_flash(socket, :error, gettext("No email addresses found"))}
|
||||
else
|
||||
# RFC 5322 uses comma as separator for email address lists
|
||||
email_string = Enum.join(formatted_emails, ", ")
|
||||
|
||||
email_count == 0 ->
|
||||
{:noreply, put_flash(socket, :error, gettext("No email addresses found"))}
|
||||
|
||||
true ->
|
||||
# RFC 5322 uses comma as separator for email address lists
|
||||
email_string = Enum.join(formatted_emails, ", ")
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> push_event("copy_to_clipboard", %{text: email_string})
|
||||
|> put_flash(
|
||||
:success,
|
||||
ngettext(
|
||||
"Copied %{count} email address to clipboard",
|
||||
"Copied %{count} email addresses to clipboard",
|
||||
email_count,
|
||||
count: email_count
|
||||
)
|
||||
)
|
||||
|> put_flash(
|
||||
:warning,
|
||||
gettext("Tip: Paste email addresses into the BCC field for privacy compliance")
|
||||
socket =
|
||||
socket
|
||||
|> push_event("copy_to_clipboard", %{text: email_string})
|
||||
|> put_flash(
|
||||
:success,
|
||||
ngettext(
|
||||
"Copied %{count} email address to clipboard",
|
||||
"Copied %{count} email addresses to clipboard",
|
||||
email_count,
|
||||
count: email_count
|
||||
)
|
||||
)
|
||||
|> put_flash(
|
||||
:warning,
|
||||
gettext("Tip: Paste email addresses into the BCC field for privacy compliance")
|
||||
)
|
||||
|
||||
{:noreply, socket}
|
||||
{:noreply, socket}
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -1812,24 +1813,79 @@ defmodule MvWeb.MemberLive.Index do
|
|||
selected_count = Enum.count(members, &MapSet.member?(selected_members, &1.id))
|
||||
any_selected? = Enum.any?(members, &MapSet.member?(selected_members, &1.id))
|
||||
|
||||
# Scope drives the trigger label: the selection when present, otherwise the
|
||||
# whole list (filtered, when a search term or any filter is active).
|
||||
scope =
|
||||
cond do
|
||||
any_selected? -> :selection
|
||||
filters_active?(socket.assigns) -> :filtered
|
||||
true -> :all
|
||||
end
|
||||
|
||||
# Copy/Mailto recipients: the members in scope that have a usable email.
|
||||
# With a selection that is the selected subset (existing behaviour); without
|
||||
# a selection it is every member in scope (deliberate behaviour change). In
|
||||
# both cases members without an email are excluded, exactly as today's
|
||||
# format_selected_member_emails does for the selection case.
|
||||
recipient_emails = scope_member_emails(members, selected_members, any_selected?)
|
||||
recipient_count = length(recipient_emails)
|
||||
|
||||
# RFC 6068: mailto URI params must use %20 for spaces, not + (encode_www_form uses +)
|
||||
mailto_bcc =
|
||||
if any_selected? do
|
||||
format_selected_member_emails(members, selected_members)
|
||||
|> Enum.join(", ")
|
||||
|> URI.encode_www_form()
|
||||
|> String.replace("+", "%20")
|
||||
else
|
||||
""
|
||||
end
|
||||
recipient_emails
|
||||
|> Enum.join(", ")
|
||||
|> URI.encode_www_form()
|
||||
|> String.replace("+", "%20")
|
||||
|
||||
mailto_disabled? = recipient_count >= Mv.Constants.max_mailto_bulk_recipients()
|
||||
|
||||
socket
|
||||
|> assign(:selected_count, selected_count)
|
||||
|> assign(:any_selected?, any_selected?)
|
||||
|> assign(:scope, scope)
|
||||
|> assign(:recipient_count, recipient_count)
|
||||
|> assign(:mailto_disabled?, mailto_disabled?)
|
||||
|> assign(:mailto_bcc, mailto_bcc)
|
||||
|> assign_export_payload()
|
||||
end
|
||||
|
||||
# Returns the formatted "Name <email>" recipient list for the current scope:
|
||||
# the selected members when any are selected, otherwise every member in the
|
||||
# (filtered) list. Members without an email are excluded in both cases.
|
||||
defp scope_member_emails(members, selected_members, true = _any_selected?),
|
||||
do: format_selected_member_emails(members, selected_members)
|
||||
|
||||
defp scope_member_emails(members, _selected_members, false = _any_selected?) do
|
||||
members
|
||||
|> Enum.filter(fn member -> member.email && member.email != "" end)
|
||||
|> Enum.map(&format_member_email/1)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns true when the member list is restricted by a non-empty search term or
|
||||
any active filter (cycle status, group, fee type, boolean custom field, or a
|
||||
date filter differing from the default). Drives the "(gefiltert)" vs "(alle)"
|
||||
trigger label and reads only assigns — no DB access.
|
||||
"""
|
||||
def filters_active?(assigns) do
|
||||
search_active?(assigns) or selection_filters_active?(assigns) or date_filter_active?(assigns)
|
||||
end
|
||||
|
||||
defp search_active?(assigns) do
|
||||
query = assigns[:query]
|
||||
is_binary(query) and query != ""
|
||||
end
|
||||
|
||||
defp selection_filters_active?(assigns) do
|
||||
not is_nil(assigns[:cycle_status_filter]) or
|
||||
map_size(assigns[:group_filters] || %{}) > 0 or
|
||||
map_size(assigns[:fee_type_filters] || %{}) > 0 or
|
||||
map_size(assigns[:boolean_custom_field_filters] || %{}) > 0
|
||||
end
|
||||
|
||||
defp date_filter_active?(assigns) do
|
||||
(assigns[:date_filters] || DateFilter.default()) != DateFilter.default()
|
||||
end
|
||||
|
||||
defp assign_export_payload(socket) do
|
||||
payload = build_export_payload(socket)
|
||||
assign(socket, :export_payload_json, Jason.encode!(payload))
|
||||
|
|
|
|||
|
|
@ -3,32 +3,15 @@
|
|||
{@content_title}
|
||||
<:actions>
|
||||
<.live_component
|
||||
module={MvWeb.Components.ExportDropdown}
|
||||
id="export-dropdown"
|
||||
module={MvWeb.Components.BulkActionsDropdown}
|
||||
id="bulk-actions-dropdown"
|
||||
export_payload_json={@export_payload_json}
|
||||
selected_count={@selected_count}
|
||||
scope={@scope}
|
||||
mailto_bcc={@mailto_bcc}
|
||||
recipient_count={@recipient_count}
|
||||
mailto_disabled?={@mailto_disabled?}
|
||||
/>
|
||||
<.button
|
||||
variant="secondary"
|
||||
id="copy-emails-btn"
|
||||
phx-hook="CopyToClipboard"
|
||||
phx-click="copy_emails"
|
||||
disabled={not @any_selected?}
|
||||
aria-label={gettext("Copy email addresses of selected members")}
|
||||
>
|
||||
<.icon name="hero-clipboard-document" />
|
||||
{gettext("Copy email addresses")} ({@selected_count})
|
||||
</.button>
|
||||
<.button
|
||||
variant="secondary"
|
||||
id="open-email-btn"
|
||||
href={"mailto:?bcc=" <> @mailto_bcc}
|
||||
disabled={not @any_selected?}
|
||||
aria-label={gettext("Open email program with BCC recipients")}
|
||||
>
|
||||
<.icon name="hero-envelope" />
|
||||
{gettext("Open in email program")}
|
||||
</.button>
|
||||
<%= if can?(@current_user, :create, Mv.Membership.Member) do %>
|
||||
<.button variant="primary" navigate={~p"/members/new"} data-testid="member-new">
|
||||
<.icon name="hero-plus" /> {gettext("New Member")}
|
||||
|
|
|
|||
|
|
@ -235,6 +235,19 @@ defmodule MvWeb.MemberLive.Show do
|
|||
<%= for custom_field <- @custom_fields do %>
|
||||
<% cfv = find_custom_field_value(@member.custom_field_values, custom_field.id) %>
|
||||
<.data_field label={custom_field.name}>
|
||||
<:label_suffix :if={custom_field.join_description}>
|
||||
<.tooltip
|
||||
content={"#{gettext("Join form:")} #{custom_field.join_description}"}
|
||||
wrap_class="ml-1 inline-flex items-center"
|
||||
>
|
||||
<span data-testid="join-description-tooltip">
|
||||
<.icon
|
||||
name="hero-information-circle"
|
||||
class="size-3.5 text-base-content/50"
|
||||
/>
|
||||
</span>
|
||||
</.tooltip>
|
||||
</:label_suffix>
|
||||
{format_custom_field_value(cfv, custom_field.value_type)}
|
||||
</.data_field>
|
||||
<% end %>
|
||||
|
|
@ -605,11 +618,14 @@ defmodule MvWeb.MemberLive.Show do
|
|||
attr :value, :string, default: nil
|
||||
attr :class, :string, default: ""
|
||||
slot :inner_block
|
||||
slot :label_suffix
|
||||
|
||||
defp data_field(assigns) do
|
||||
~H"""
|
||||
<dl class={@class}>
|
||||
<dt class="text-sm font-medium text-base-content/70">{@label}</dt>
|
||||
<dt class="text-sm font-medium text-base-content/70 flex items-center">
|
||||
{@label}{render_slot(@label_suffix)}
|
||||
</dt>
|
||||
<dd class="mt-1 text-base-content">
|
||||
<%= if @inner_block != [] do %>
|
||||
{render_slot(@inner_block)}
|
||||
|
|
|
|||
BIN
logo.png
Normal file
BIN
logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 34 KiB |
4
mix.exs
4
mix.exs
|
|
@ -39,8 +39,8 @@ defmodule Mv.MixProject do
|
|||
[
|
||||
{:tidewave, "~> 0.5", only: [:dev]},
|
||||
{:sourceror, "~> 1.8", only: [:dev, :test]},
|
||||
{:live_debugger, "~> 0.8", only: [:dev]},
|
||||
{:ash_admin, "~> 0.14"},
|
||||
{:live_debugger, "~> 1.0", only: [:dev]},
|
||||
{:ash_admin, "~> 1.0"},
|
||||
{:ash_postgres, "~> 2.0"},
|
||||
{:ash_phoenix, "~> 2.0"},
|
||||
{:ash, "~> 3.0"},
|
||||
|
|
|
|||
36
mix.lock
36
mix.lock
|
|
@ -1,6 +1,6 @@
|
|||
%{
|
||||
"ash": {:hex, :ash, "3.24.7", "6e2f32011e7c8f0809dae36712ccfb2efaf3c669cbda7443685436e80acdebf7", [:mix], [{:crux, ">= 0.1.2 and < 1.0.0-0", [hex: :crux, repo: "hexpm", optional: false]}, {:decimal, "~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.7", [hex: :ecto, repo: "hexpm", optional: false]}, {:ets, "~> 0.8", [hex: :ets, repo: "hexpm", optional: false]}, {:igniter, ">= 0.6.29 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: false]}, {:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}, {:plug, ">= 0.0.0", [hex: :plug, repo: "hexpm", optional: true]}, {:reactor, "~> 1.0", [hex: :reactor, repo: "hexpm", optional: false]}, {:simple_sat, ">= 0.1.1 and < 1.0.0-0", [hex: :simple_sat, repo: "hexpm", optional: true]}, {:spark, ">= 2.6.0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.3", [hex: :splode, repo: "hexpm", optional: false]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.1", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "c9fb4d21c3c8bb85636338d448afdc283dd98a433d869e4b2210ac57ade00624"},
|
||||
"ash_admin": {:hex, :ash_admin, "0.14.0", "1a8f61f6cef7af757852e94a916a152bd3f3c3620b094de84a008120675adccd", [:mix], [{:ash, ">= 3.4.63 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_phoenix, ">= 2.1.8 and < 3.0.0-0", [hex: :ash_phoenix, repo: "hexpm", optional: false]}, {:cinder, "~> 0.9", [hex: :cinder, repo: "hexpm", optional: false]}, {:gettext, "~> 0.26 or ~> 1.0", [hex: :gettext, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.7", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.1-rc", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}], "hexpm", "d3bc34c266491ae3177f2a76ad97bbe916c4d3a41d56196db9d95e76413b3455"},
|
||||
"ash": {:hex, :ash, "3.27.7", "349e47b9fc293c8de56866f900f6e1a3a5deea1e110d205749f94a9833431811", [:mix], [{:crux, ">= 0.1.2 and < 1.0.0-0", [hex: :crux, repo: "hexpm", optional: false]}, {:decimal, "~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.7", [hex: :ecto, repo: "hexpm", optional: false]}, {:ets, "~> 0.8", [hex: :ets, repo: "hexpm", optional: false]}, {:igniter, ">= 0.6.29 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: false]}, {:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}, {:plug, ">= 0.0.0", [hex: :plug, repo: "hexpm", optional: true]}, {:reactor, "~> 1.0", [hex: :reactor, repo: "hexpm", optional: false]}, {:simple_sat, ">= 0.1.1 and < 1.0.0-0", [hex: :simple_sat, repo: "hexpm", optional: true]}, {:spark, ">= 2.6.0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.3", [hex: :splode, repo: "hexpm", optional: false]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.1", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "eb8a1a74090d7f1753a63fe422cd493b7f50736e2d95d280ccfb508956dccc1d"},
|
||||
"ash_admin": {:hex, :ash_admin, "1.1.0", "df7c8075347ca9229f132648534a33319f29ae5aceed6c1015d138bba1a4811f", [:mix], [{:ash, ">= 3.4.63 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_phoenix, ">= 2.3.20 and < 3.0.0-0", [hex: :ash_phoenix, repo: "hexpm", optional: false]}, {:cinder, "~> 0.9", [hex: :cinder, repo: "hexpm", optional: false]}, {:gettext, "~> 0.26 or ~> 1.0", [hex: :gettext, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.7", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.1-rc", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}], "hexpm", "f8bd6c08b584a315a9574c7bbe9c1c914bc5c51838045994b0e5369871f9b3d8"},
|
||||
"ash_authentication": {:hex, :ash_authentication, "4.13.7", "421b5ddb516026f6794435980a632109ec116af2afa68a45e15fb48b41c92cfa", [:mix], [{:argon2_elixir, "~> 4.0", [hex: :argon2_elixir, repo: "hexpm", optional: true]}, {:ash, "~> 3.7", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_postgres, ">= 2.6.8 and < 3.0.0-0", [hex: :ash_postgres, repo: "hexpm", optional: true]}, {:assent, "> 0.2.0 and < 0.3.0", [hex: :assent, repo: "hexpm", optional: false]}, {:bcrypt_elixir, "~> 3.0", [hex: :bcrypt_elixir, repo: "hexpm", optional: false]}, {:castore, "~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:finch, "~> 0.19", [hex: :finch, repo: "hexpm", optional: false]}, {:igniter, "~> 0.4", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:joken, "~> 2.5", [hex: :joken, repo: "hexpm", optional: false]}, {:plug, "~> 1.13", [hex: :plug, repo: "hexpm", optional: false]}, {:spark, "~> 2.0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.2", [hex: :splode, repo: "hexpm", optional: false]}], "hexpm", "0d45ac3fdcca6902dabbe161ce63e9cea8f90583863c2e14261c9309e5837121"},
|
||||
"ash_authentication_phoenix": {:hex, :ash_authentication_phoenix, "2.16.0", "02045ecde9eeb30ab1bfffbdf693c64426af24902bcd533765eba725b9b9f46f", [:mix], [{:ash, "~> 3.0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_authentication, "~> 4.10", [hex: :ash_authentication, repo: "hexpm", optional: false]}, {:ash_phoenix, ">= 2.3.11 and < 3.0.0-0", [hex: :ash_phoenix, repo: "hexpm", optional: false]}, {:bcrypt_elixir, "~> 3.0", [hex: :bcrypt_elixir, repo: "hexpm", optional: false]}, {:gettext, "~> 0.26 or ~> 1.0", [hex: :gettext, repo: "hexpm", optional: true]}, {:igniter, ">= 0.5.25 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.6", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_html_helpers, "~> 1.0", [hex: :phoenix_html_helpers, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.1", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}, {:slugify, "~> 1.3", [hex: :slugify, repo: "hexpm", optional: false]}], "hexpm", "1107a45af771ee7c02ebe82abcaf9a778096e66b3e6cb2b6e614d22d1fe385f7"},
|
||||
"ash_phoenix": {:hex, :ash_phoenix, "2.3.22", "f59a347ee09e1fa9973fe1b2faf7f8f793acc14dc09341062783b8eb1a9f5c99", [:mix], [{:ash, ">= 3.5.13 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:igniter, "~> 0.6", [hex: :igniter, repo: "hexpm", optional: true]}, {:inertia, "~> 2.3", [hex: :inertia, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.5.6 or ~> 1.6", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.20.3 or ~> 1.0-rc.1", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:spark, ">= 2.2.29 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}], "hexpm", "4b34ea84e122c238ad1843888b8fd4d21aec27605b9b1e6e27e1b70329560fbb"},
|
||||
|
|
@ -11,18 +11,18 @@
|
|||
"bcrypt_elixir": {:hex, :bcrypt_elixir, "3.3.2", "d50091e3c9492d73e17fc1e1619a9b09d6a5ef99160eb4d736926fd475a16ca3", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "471be5151874ae7931911057d1467d908955f93554f7a6cd1b7d804cac8cef53"},
|
||||
"bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"},
|
||||
"bypass": {:hex, :bypass, "2.1.0", "909782781bf8e20ee86a9cabde36b259d44af8b9f38756173e8f5e2e1fabb9b1", [:mix], [{:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.0", [hex: :plug_cowboy, repo: "hexpm", optional: false]}, {:ranch, "~> 1.3", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "d9b5df8fa5b7a6efa08384e9bbecfe4ce61c77d28a4282f79e02f1ef78d96b80"},
|
||||
"castore": {:hex, :castore, "1.0.18", "5e43ef0ec7d31195dfa5a65a86e6131db999d074179d2ba5a8de11fe14570f55", [:mix], [], "hexpm", "f393e4fe6317829b158fb74d86eb681f737d2fe326aa61ccf6293c4104957e34"},
|
||||
"castore": {:hex, :castore, "1.0.19", "6903cabdfd9d1af46454126e7c8385186659dd33ecfb74a885cae52221ad6109", [:mix], [], "hexpm", "3669e6cab13f54c2df26b3e6833745d647f35b6e30d8ddd5975df0d5c842ca98"},
|
||||
"cc_precompiler": {:hex, :cc_precompiler, "0.1.11", "8c844d0b9fb98a3edea067f94f616b3f6b29b959b6b3bf25fee94ffe34364768", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "3427232caf0835f94680e5bcf082408a70b48ad68a5f5c0b02a3bea9f3a075b9"},
|
||||
"cinder": {:hex, :cinder, "0.12.1", "02ae4988e025fb32c37e4e7f2e491586b952918c0dd99d856da13271cd680e16", [:mix], [{:ash, "~> 3.0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_phoenix, "~> 2.3", [hex: :ash_phoenix, repo: "hexpm", optional: false]}, {:gettext, "~> 1.0.0", [hex: :gettext, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:spark, "~> 2.0", [hex: :spark, repo: "hexpm", optional: false]}], "hexpm", "a48b5677c1f57619d9d7564fb2bd7928f93750a2e8c0b1b145852a30ecf2aa20"},
|
||||
"cinder": {:hex, :cinder, "0.14.0", "ae0866aaa3c166cc882de04f1c9d9906d5be0e5cfb8d7e9f7d62c097d97013e7", [:mix], [{:ash, "~> 3.0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_phoenix, "~> 2.3", [hex: :ash_phoenix, repo: "hexpm", optional: false]}, {:gettext, "~> 1.0.0", [hex: :gettext, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}], "hexpm", "e05d9c6c75dc839faaaa2063e46cd69dda3907718af71964231c93d2f602bc49"},
|
||||
"circular_buffer": {:hex, :circular_buffer, "1.0.0", "25c004da0cba7bd8bc1bdabded4f9a902d095e20600fd15faf1f2ffbaea18a07", [:mix], [], "hexpm", "c829ec31c13c7bafd1f546677263dff5bfb006e929f25635878ac3cfba8749e5"},
|
||||
"comeonin": {:hex, :comeonin, "5.5.1", "5113e5f3800799787de08a6e0db307133850e635d34e9fab23c70b6501669510", [:mix], [], "hexpm", "65aac8f19938145377cee73973f192c5645873dcf550a8a6b18187d17c13ccdb"},
|
||||
"cowboy": {:hex, :cowboy, "2.15.0", "9cfe86ed7117bf045e10adbedb0170af7be57f2a3637e7be143433d8dd267396", [:make, :rebar3], [{:cowlib, ">= 2.16.0 and < 3.0.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, ">= 1.8.0 and < 3.0.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "179fb65140fb440a17b767ad53b755081506f9596c4db5c49c0396d8c8643668"},
|
||||
"cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"},
|
||||
"cowlib": {:hex, :cowlib, "2.16.1", "318d385d55f657e9a5005838c4e426e13dcd724a691438384b6165a69687e531", [:make, :rebar3], [], "hexpm", "58f1e425a9e04176f1d30e20116f57c4e90ef0e187552e9741c465bdf4044f70"},
|
||||
"credo": {:hex, :credo, "1.7.18", "5c5596bf7aedf9c8c227f13272ac499fe8eae6237bd326f2f07dfc173786f042", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "a189d164685fd945809e862fe76a7420c4398fa288d76257662aecb909d6b3e5"},
|
||||
"crux": {:hex, :crux, "0.1.2", "4441c9e3a34f1e340954ce96b9ad5a2de13ceb4f97b3f910211227bb92e2ca90", [:mix], [{:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}, {:simple_sat, ">= 0.1.1 and < 1.0.0-0", [hex: :simple_sat, repo: "hexpm", optional: true]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: true]}], "hexpm", "563ea3748ebfba9cc078e6d198a1d6a06015a8fae503f0b721363139f0ddb350"},
|
||||
"crux": {:hex, :crux, "0.1.3", "c698dee09d811678dcddad11a02a832c6bff100f1a7aee49ac44c87485bdbac8", [:mix], [{:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}, {:simple_sat, ">= 0.1.1 and < 1.0.0-0", [hex: :simple_sat, repo: "hexpm", optional: true]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: true]}], "hexpm", "04188ea9c1cee13e3ef132417200765857402dcc581f45a8a7862eec3b0530ff"},
|
||||
"db_connection": {:hex, :db_connection, "2.10.1", "d5465f6bcc125c1b8981c1dbf23c193ca16f446ec0b25832dc174f74f18be510", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "18ed94c6e627b4bf452dbd4df61b69a35a1e768525140bc1917b7a685026a6a3"},
|
||||
"decimal": {:hex, :decimal, "3.1.0", "9ede268cff827e6f0c4fb1b34747c82630dce5d7b877dfb22ec8f0cb25855fce", [:mix], [], "hexpm", "e8b3efb3bb3a13cb5e4268ffe128569067b1972e9dee013537c71a5b073168f9"},
|
||||
"decimal": {:hex, :decimal, "3.1.1", "430d87b04011ce6cbd4fd205be758311a81f87d552d40904abd00f015935b1d0", [:mix], [], "hexpm", "c5f25f2ced74a0587d03e6023f595db8e924c9d3922c8c8ffd9edfc4498cf1f6"},
|
||||
"dialyxir": {:hex, :dialyxir, "1.4.7", "dda948fcee52962e4b6c5b4b16b2d8fa7d50d8645bbae8b8685c3f9ecb7f5f4d", [:mix], [{:erlex, ">= 0.2.8", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "b34527202e6eb8cee198efec110996c25c5898f43a4094df157f8d28f27d9efe"},
|
||||
"dns_cluster": {:hex, :dns_cluster, "0.2.0", "aa8eb46e3bd0326bd67b84790c561733b25c5ba2fe3c7e36f28e88f384ebcb33", [:mix], [], "hexpm", "ba6f1893411c69c01b9e8e8f772062535a4cf70f3f35bcc964a324078d8c8240"},
|
||||
"ecto": {:hex, :ecto, "3.13.6", "352135b474f91d1ab99a1b502171d207e9db60421c9e3d0ecab4c7ab96b24d14", [:mix], [{:decimal, "~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "8afa059bc16cd2c94739ec0a11e3e5df69d828125119109bef35f20a21a76af2"},
|
||||
|
|
@ -32,10 +32,11 @@
|
|||
"erlex": {:hex, :erlex, "0.2.9", "7debbbaa9f4f368b8cd648983e0f1d7963028508e9c59e9d4ed504e94ef52a55", [:mix], [], "hexpm", "8cfffc0ec7159e6d73de2ab28a588064de80f88b2798d5cbe4482cbbc200178b"},
|
||||
"esbuild": {:hex, :esbuild, "0.10.0", "b0aa3388a1c23e727c5a3e7427c932d89ee791746b0081bbe56103e9ef3d291f", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "468489cda427b974a7cc9f03ace55368a83e1a7be12fba7e30969af78e5f8c70"},
|
||||
"ets": {:hex, :ets, "0.9.0", "79c6a6c205436780486f72d84230c6cba2f8a9920456750ddd1e47389107d5fd", [:mix], [], "hexpm", "2861fdfb04bcaeff370f1a5904eec864f0a56dcfebe5921ea9aadf2a481c822b"},
|
||||
"ex_ast": {:hex, :ex_ast, "0.12.0", "052ad63711da41b7efbfb3490dbf3d757bb67caec17d02f6deb0db4a0363e5f6", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.7", [hex: :sourceror, repo: "hexpm", optional: false]}], "hexpm", "66b4797f157d32f0a63c6da227515f78816c0ac8f621f6d7a2b22108e7b4dd85"},
|
||||
"ex_phone_number": {:hex, :ex_phone_number, "0.4.10", "11809f6600b2ecb0a2e75d496c2ec2f273d49d1e2f58b2be2667decb0aabfb43", [:mix], [{:sweet_xml, "~> 0.7", [hex: :sweet_xml, repo: "hexpm", optional: false]}], "hexpm", "eefccf58d8149d64af658721bff0edcb9e9b8943f74000ede151948ef03046c1"},
|
||||
"expo": {:hex, :expo, "1.1.1", "4202e1d2ca6e2b3b63e02f69cfe0a404f77702b041d02b58597c00992b601db5", [:mix], [], "hexpm", "5fb308b9cb359ae200b7e23d37c76978673aa1b06e2b3075d814ce12c5811640"},
|
||||
"file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"},
|
||||
"finch": {:hex, :finch, "0.21.0", "b1c3b2d48af02d0c66d2a9ebfb5622be5c5ecd62937cf79a88a7f98d48a8290c", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "87dc6e169794cb2570f75841a19da99cfde834249568f2a5b121b809588a4377"},
|
||||
"finch": {:hex, :finch, "0.22.0", "5c48fa6f9706a78eb9036cacb67b8b996b4e66d111c543f4c29bb0f879a6806b", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.8", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b94e83c47780fc6813f746a1f1a34ee65cda42da4c5ea26a68f0acc4498e23dc"},
|
||||
"fine": {:hex, :fine, "0.1.6", "4bf7151493443c454aac9f2fa2f34f5fefd0346a83fb5586a016c4a135c63247", [:mix], [], "hexpm", "5638eb4495488e885ebec167fa57973e5c35e1a50c344eb7666c90ec1c4e3b12"},
|
||||
"gen_smtp": {:hex, :gen_smtp, "1.3.0", "62c3d91f0dcf6ce9db71bcb6881d7ad0d1d834c7f38c13fa8e952f4104a8442e", [:rebar3], [{:ranch, ">= 1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "0b73fbf069864ecbce02fe653b16d3f35fd889d0fdd4e14527675565c39d84e6"},
|
||||
"gettext": {:hex, :gettext, "1.0.2", "5457e1fd3f4abe47b0e13ff85086aabae760497a3497909b8473e0acee57673b", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "eab805501886802071ad290714515c8c4a17196ea76e5afc9d06ca85fb1bfeb3"},
|
||||
|
|
@ -44,7 +45,7 @@
|
|||
"heroicons": {:git, "https://github.com/tailwindlabs/heroicons.git", "0435d4ca364a608cc75e2f8683d374e55abbae26", [tag: "v2.2.0", sparse: "optimized", depth: 1]},
|
||||
"hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"},
|
||||
"idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"},
|
||||
"igniter": {:hex, :igniter, "0.7.9", "8c573440b8127fd80be8220fb197e7422317a81072054fcc0b336029f035a416", [:mix], [{:glob_ex, "~> 0.1.7", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:owl, "~> 0.11", [hex: :owl, repo: "hexpm", optional: false]}, {:phx_new, "~> 1.7", [hex: :phx_new, repo: "hexpm", optional: true]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}, {:rewrite, ">= 1.1.1 and < 2.0.0-0", [hex: :rewrite, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.4", [hex: :sourceror, repo: "hexpm", optional: false]}, {:spitfire, ">= 0.1.3 and < 1.0.0-0", [hex: :spitfire, repo: "hexpm", optional: false]}], "hexpm", "123513d09f3af149db851aad8492b5b49f861d2c466a72031b2a0cbd9f45526f"},
|
||||
"igniter": {:hex, :igniter, "0.8.1", "3c6ea47f3a6031015e29da8b4ba5c685f0a2e409facf63041fd83e982ca3aa89", [:mix], [{:ex_ast, "~> 0.5", [hex: :ex_ast, repo: "hexpm", optional: false]}, {:glob_ex, "~> 0.1.7", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:jason, "~> 1.4.5", [hex: :jason, repo: "hexpm", optional: false]}, {:owl, "~> 0.11", [hex: :owl, repo: "hexpm", optional: false]}, {:phx_new, "~> 1.7", [hex: :phx_new, repo: "hexpm", optional: true]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}, {:rewrite, ">= 1.1.1 and < 2.0.0-0", [hex: :rewrite, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.4", [hex: :sourceror, repo: "hexpm", optional: false]}, {:spitfire, ">= 0.1.3 and < 1.0.0-0", [hex: :spitfire, repo: "hexpm", optional: false]}], "hexpm", "d99472e6daf3bfc3675d699c6c7ace9196f377207aab83e09d7b95e9d90e8ae8"},
|
||||
"imprintor": {:hex, :imprintor, "0.6.0", "c6dfeb3c47d15cfb7e8491cf0a40548b3b9e37d0fc33940ca6dd283d36c4bada", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:rustler, ">= 0.0.0", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.8", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "9c26b0e665c0ab183860e77c450e0b0a4e390249e8322328dbe1be70d0fbca36"},
|
||||
"iterex": {:hex, :iterex, "0.1.2", "58f9b9b9a22a55cbfc7b5234a9c9c63eaac26d276b3db80936c0e1c60355a5a6", [:mix], [], "hexpm", "2e103b8bcc81757a9af121f6dc0df312c9a17220f302b1193ef720460d03029d"},
|
||||
"jason": {:hex, :jason, "1.4.5", "2e3a008590b0b8d7388c20293e9dcc9cf3e5d642fd2a114e4cbbb52e595d940a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "b0c823996102bcd0239b3c2444eb00409b72f6a140c1950bc8b457d836b30684"},
|
||||
|
|
@ -52,22 +53,23 @@
|
|||
"jose": {:hex, :jose, "1.11.12", "06e62b467b61d3726cbc19e9b5489f7549c37993de846dfb3ee8259f9ed208b3", [:mix, :rebar3], [], "hexpm", "31e92b653e9210b696765cdd885437457de1add2a9011d92f8cf63e4641bab7b"},
|
||||
"lazy_html": {:hex, :lazy_html, "0.1.11", "136c8e9cd616b4f4e9c1562daa683880891120b759606dc4c3b6b18058ba5d79", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.9.0", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:fine, "~> 0.1.0", [hex: :fine, repo: "hexpm", optional: false]}], "hexpm", "3b1be592929c31eca1a21673d25696e5c14cddfe922d9d1a3e3b48be4163883b"},
|
||||
"libgraph": {:hex, :libgraph, "0.16.0", "3936f3eca6ef826e08880230f806bfea13193e49bf153f93edcf0239d4fd1d07", [:mix], [], "hexpm", "41ca92240e8a4138c30a7e06466acc709b0cbb795c643e9e17174a178982d6bf"},
|
||||
"live_debugger": {:hex, :live_debugger, "0.8.0", "02545b5accdf42f48aa9bddabdd7574ddf532d5aa0cb0270d7e35031bc184286", [:mix], [{:file_system, "~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:igniter, ">= 0.5.40 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.7", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.20.8 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}], "hexpm", "4ecdec3c4267e665afd2266e3cb86239dd457f8c8fc4e63de6e150a2a7665920"},
|
||||
"live_debugger": {:hex, :live_debugger, "1.0.0", "0bbcbdcb3b40b6862b6dfbb76579e7832e2787fee643031e5958f597def6fb32", [:mix], [{:file_system, "~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:igniter, ">= 0.5.40 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.7", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_live_view, ">= 1.1.7 and < 2.0.0-0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}], "hexpm", "b80f5d9db874d3270eb534738a10982b2b4ce55d58f94544e43b4a36111585fc"},
|
||||
"luhn": {:hex, :luhn, "0.3.3", "5aa0c6a32c2db4b9db9f9b883ba8301c1ae169d57199b9e6cb1ba2707bc51d96", [:mix], [], "hexpm", "3e823a913a25aab51352c727f135278d22954874d5f0835be81ed4fec3daf78d"},
|
||||
"mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"},
|
||||
"mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"},
|
||||
"mint": {:hex, :mint, "1.9.0", "d6f534c2a3e98b2a8cc749b4796eb77e9e3af79a76f96e4c74035a827de0d318", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "007154c7d8c43916aed3c93afd1f11aebbaa9c5ff4b7ba55ebe0d17ee0296042"},
|
||||
"mix_audit": {:hex, :mix_audit, "2.1.5", "c0f77cee6b4ef9d97e37772359a187a166c7a1e0e08b50edf5bf6959dfe5a016", [:make, :mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:yaml_elixir, "~> 2.11", [hex: :yaml_elixir, repo: "hexpm", optional: false]}], "hexpm", "87f9298e21da32f697af535475860dc1d3617a010e0b418d2ec6142bc8b42d69"},
|
||||
"multigraph": {:hex, :multigraph, "0.16.1-mg.4", "2bbe149f5411b0e3bf0624c7bf2e3da2738efeac2f9a67bbbcb807ab171f0a76", [:mix], [], "hexpm", "b9f3e2577cef4658eeedf97c76d22a86d33a7aab702a93c1da9c122e849e9037"},
|
||||
"nimble_csv": {:hex, :nimble_csv, "1.3.0", "b7f998dc62b222bce9596e46f028c7a5af04cb5dde6df2ea197c583227c54971", [:mix], [], "hexpm", "41ccdc18f7c8f8bb06e84164fc51635321e80d5a3b450761c4997d620925d619"},
|
||||
"nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"},
|
||||
"nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"},
|
||||
"owl": {:hex, :owl, "0.13.0", "26010e066d5992774268f3163506972ddac0a7e77bfe57fa42a250f24d6b876e", [:mix], [{:ucwidth, "~> 0.2", [hex: :ucwidth, repo: "hexpm", optional: true]}], "hexpm", "59bf9d11ce37a4db98f57cb68fbfd61593bf419ec4ed302852b6683d3d2f7475"},
|
||||
"phoenix": {:hex, :phoenix, "1.8.6", "7106a0da114619c4b12b056bbaef39fdbc75d3d0cf9cf24af683364064c12dc3", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "d0d0b7931916c8196b6903a1efa118b5da28487e7a75ad32a54dfd77de59d421"},
|
||||
"phoenix": {:hex, :phoenix, "1.8.7", "d8d755b4ff4b449f610223dd706b4ae64155cb720d3dc09c706c079ecea189e4", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "47352f72d6ab31009ef77516b1b3a14745be97b54061fd458031b9d8294869d5"},
|
||||
"phoenix_ecto": {:hex, :phoenix_ecto, "4.7.0", "75c4b9dfb3efdc42aec2bd5f8bccd978aca0651dbcbc7a3f362ea5d9d43153c6", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "1d75011e4254cb4ddf823e81823a9629559a1be93b4321a6a5f11a5306fbf4cc"},
|
||||
"phoenix_html": {:hex, :phoenix_html, "4.3.0", "d3577a5df4b6954cd7890c84d955c470b5310bb49647f0a114a6eeecc850f7ad", [:mix], [], "hexpm", "3eaa290a78bab0f075f791a46a981bbe769d94bc776869f4f3063a14f30497ad"},
|
||||
"phoenix_html_helpers": {:hex, :phoenix_html_helpers, "1.0.1", "7eed85c52eff80a179391036931791ee5d2f713d76a81d0d2c6ebafe1e11e5ec", [:mix], [{:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "cffd2385d1fa4f78b04432df69ab8da63dc5cf63e07b713a4dcf36a3740e3090"},
|
||||
"phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.8.7", "405880012cb4b706f26dd1c6349125bfc903fb9e44d1ea668adaf4e04d4884b7", [:mix], [{:ecto, "~> 3.6.2 or ~> 3.7", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_mysql_extras, "~> 0.5", [hex: :ecto_mysql_extras, repo: "hexpm", optional: true]}, {:ecto_psql_extras, "~> 0.7", [hex: :ecto_psql_extras, repo: "hexpm", optional: true]}, {:ecto_sqlite3_extras, "~> 1.1.7 or ~> 1.2.0", [hex: :ecto_sqlite3_extras, repo: "hexpm", optional: true]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.19 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6 or ~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "3a8625cab39ec261d48a13b7468dc619c0ede099601b084e343968309bd4d7d7"},
|
||||
"phoenix_live_reload": {:hex, :phoenix_live_reload, "1.6.2", "b18b0773a1ba77f28c52decbb0f10fd1ac4d3ae5b8632399bbf6986e3b665f62", [:mix], [{:file_system, "~> 0.2.10 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "d1f89c18114c50d394721365ffb428cce24f1c13de0467ffa773e2ff4a30d5b9"},
|
||||
"phoenix_live_view": {:hex, :phoenix_live_view, "1.1.30", "a84af1610755dc208da35d4d45564485edbf18c3f3c77373c4a650dc994cdcdb", [:mix], [{:igniter, ">= 0.6.16 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:lazy_html, "~> 0.1.0", [hex: :lazy_html, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0-rc", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a353c51ac1e3190910f01a6100c7d5cc02c5e22e7374fd817bd3aedd21149039"},
|
||||
"phoenix_live_view": {:hex, :phoenix_live_view, "1.1.31", "c45c85df509dd79c917bc530e26c71299e3920850f65ea52ab6a19ccee66875a", [:mix], [{:igniter, ">= 0.6.16 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:lazy_html, "~> 0.1.0", [hex: :lazy_html, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0-rc", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "2f53cc6a9e149f30449341c2775990819d97e3b22338fe719c4d30342e6f9638"},
|
||||
"phoenix_pubsub": {:hex, :phoenix_pubsub, "2.2.0", "ff3a5616e1bed6804de7773b92cbccfc0b0f473faf1f63d7daf1206c7aeaaa6f", [:mix], [], "hexpm", "adc313a5bf7136039f63cfd9668fde73bba0765e0614cba80c06ac9460ff3e96"},
|
||||
"phoenix_swoosh": {:hex, :phoenix_swoosh, "1.2.1", "b74ccaa8046fbc388a62134360ee7d9742d5a8ae74063f34eb050279de7a99e1", [:mix], [{:finch, "~> 0.8", [hex: :finch, repo: "hexpm", optional: true]}, {:hackney, "~> 1.10", [hex: :hackney, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6", [hex: :phoenix, repo: "hexpm", optional: true]}, {:phoenix_html, "~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_view, "~> 1.0 or ~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}, {:swoosh, "~> 1.5", [hex: :swoosh, repo: "hexpm", optional: false]}], "hexpm", "4000eeba3f9d7d1a6bf56d2bd56733d5cadf41a7f0d8ffe5bb67e7d667e204a2"},
|
||||
"phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"},
|
||||
|
|
@ -78,15 +80,15 @@
|
|||
"plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"},
|
||||
"postgrex": {:hex, :postgrex, "0.22.2", "4aec14df2a72722aee92492566edbeeb44e233ecb86b1915d03136297ef1385d", [:mix], [{:db_connection, "~> 2.9", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "8946382ddb06294f56026ac4278b3cc212bac8a2c82ed68b4087819ed1abc53b"},
|
||||
"ranch": {:hex, :ranch, "1.8.1", "208169e65292ac5d333d6cdbad49388c1ae198136e4697ae2f474697140f201c", [:make, :rebar3], [], "hexpm", "aed58910f4e21deea992a67bf51632b6d60114895eb03bb392bb733064594dd0"},
|
||||
"reactor": {:hex, :reactor, "1.0.1", "ca3b5cf3c04ec8441e67ea2625d0294939822060b1bfd00ffdaaf75b7682d991", [:mix], [{:igniter, "~> 0.4", [hex: :igniter, repo: "hexpm", optional: true]}, {:iterex, "~> 0.1", [hex: :iterex, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:libgraph, "~> 0.16", [hex: :libgraph, repo: "hexpm", optional: false]}, {:spark, ">= 2.3.3 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.2", [hex: :splode, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.2", [hex: :telemetry, repo: "hexpm", optional: false]}, {:yaml_elixir, "~> 2.11", [hex: :yaml_elixir, repo: "hexpm", optional: false]}, {:ymlr, "~> 5.0", [hex: :ymlr, repo: "hexpm", optional: false]}], "hexpm", "3497db2b204c9a3cabdaf1b26d2405df1dfbb138ce0ce50e616e9db19fec0043"},
|
||||
"req": {:hex, :req, "0.5.17", "0096ddd5b0ed6f576a03dde4b158a0c727215b15d2795e59e0916c6971066ede", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "0b8bc6ffdfebbc07968e59d3ff96d52f2202d0536f10fef4dc11dc02a2a43e39"},
|
||||
"reactor": {:hex, :reactor, "1.0.2", "79e4e81d016ab0016afd10bb4c18cb3a574f08f10f8e53be5f08ce27f8eed541", [:mix], [{:igniter, "~> 0.4", [hex: :igniter, repo: "hexpm", optional: true]}, {:iterex, "~> 0.1", [hex: :iterex, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:multigraph, "~> 0.16.1-mg.2", [hex: :multigraph, repo: "hexpm", optional: false]}, {:spark, ">= 2.3.3 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.2", [hex: :splode, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.2", [hex: :telemetry, repo: "hexpm", optional: false]}, {:yaml_elixir, "~> 2.11", [hex: :yaml_elixir, repo: "hexpm", optional: false]}, {:ymlr, "~> 5.0", [hex: :ymlr, repo: "hexpm", optional: false]}], "hexpm", "19fd55aaaadaae28f55133351051c25d4ac217f99e3e5a67940cc4a321e3948e"},
|
||||
"req": {:hex, :req, "0.5.18", "48e6431cb4135e8a7815e745177485369a9b4a9924d5fe68ca00eb09ceaed1ef", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.21.0 or ~> 0.22.0", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "fa03812c440a9754bf34355e0c5d4f3ed316458db62e3284b7a352ef8dc0b996"},
|
||||
"rewrite": {:hex, :rewrite, "1.3.0", "67448ba7975690b35ba7e7f35717efcce317dbd5963cb0577aa7325c1923121a", [:mix], [{:glob_ex, "~> 0.1", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.0", [hex: :sourceror, repo: "hexpm", optional: false]}, {:text_diff, "~> 0.1", [hex: :text_diff, repo: "hexpm", optional: false]}], "hexpm", "d111ac7ff3a58a802ef4f193bbd1831e00a9c57b33276e5068e8390a212714a5"},
|
||||
"rustler_precompiled": {:hex, :rustler_precompiled, "0.9.0", "3a052eda09f3d2436364645cc1f13279cf95db310eb0c17b0d8f25484b233aa0", [:mix], [{:rustler, "~> 0.23", [hex: :rustler, repo: "hexpm", optional: true]}], "hexpm", "471d97315bd3bf7b64623418b3693eedd8e47de3d1cb79a0ac8f9da7d770d94c"},
|
||||
"slugify": {:hex, :slugify, "1.3.1", "0d3b8b7e5c1eeaa960e44dce94382bee34a39b3ea239293e457a9c5b47cc6fd3", [:mix], [], "hexpm", "cb090bbeb056b312da3125e681d98933a360a70d327820e4b7f91645c4d8be76"},
|
||||
"sobelow": {:hex, :sobelow, "0.14.1", "2f81e8632f15574cba2402bcddff5497b413c01e6f094bc0ab94e83c2f74db81", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "8fac9a2bd90fdc4b15d6fca6e1608efb7f7c600fa75800813b794ee9364c87f2"},
|
||||
"sourceror": {:hex, :sourceror, "1.12.0", "da354c5f35aad3cc1132f5d5b0d8437d865e2661c263260480bab51b5eedb437", [:mix], [], "hexpm", "755703683bd014ebcd5de9acc24b68fb874a660a568d1d63f8f98cd8a6ef9cd0"},
|
||||
"spark": {:hex, :spark, "2.7.0", "e685b33c038f12851993880bb7e3b326117612eb746fe15828678c152f8321c6", [:mix], [{:igniter, ">= 0.3.64 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: true]}, {:sourceror, "~> 1.2", [hex: :sourceror, repo: "hexpm", optional: true]}], "hexpm", "e2f675fbda32375b01d9ee7c652671531027fd043bf4a91bafdb2ab716aa1122"},
|
||||
"spitfire": {:hex, :spitfire, "0.3.11", "79dfcb033762470de472c1c26ea2b4e3aca74700c685dbffd9a13466272c323d", [:mix], [], "hexpm", "eb6e2dadf63214e8bfe65ca9788cef2b03b01027365d78d3c0e3d9ebd3d5b7b4"},
|
||||
"spitfire": {:hex, :spitfire, "0.3.12", "0f7780e4c6ea3753b65ea0c4924f3dfd5c21a51aaa734ffb9dd0b68d2544f27e", [:mix], [], "hexpm", "a389931287b85330c0e954ab06447e198516ab368a232a0200ed77ca13ca9acf"},
|
||||
"splode": {:hex, :splode, "0.3.1", "9843c54f84f71b7833fec3f0be06c3cfb5be6b35960ee195ea4fad84b1c25030", [:mix], [], "hexpm", "8f2309b6ec2ecbb01435656429ed1d9ed04ba28797a3280c3b0d1217018ecfbd"},
|
||||
"stream_data": {:hex, :stream_data, "1.3.0", "bde37905530aff386dea1ddd86ecbf00e6642dc074ceffc10b7d4e41dfd6aac9", [:mix], [], "hexpm", "3cc552e286e817dca43c98044c706eec9318083a1480c52ae2688b08e2936e3c"},
|
||||
"sweet_xml": {:hex, :sweet_xml, "0.7.5", "803a563113981aaac202a1dbd39771562d0ad31004ddbfc9b5090bdcd5605277", [:mix], [], "hexpm", "193b28a9b12891cae351d81a0cead165ffe67df1b73fe5866d10629f4faefb12"},
|
||||
|
|
@ -96,13 +98,13 @@
|
|||
"telemetry_metrics": {:hex, :telemetry_metrics, "1.1.0", "5bd5f3b5637e0abea0426b947e3ce5dd304f8b3bc6617039e2b5a008adc02f8f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e7b79e8ddfde70adb6db8a6623d1778ec66401f366e9a8f5dd0955c56bc8ce67"},
|
||||
"telemetry_poller": {:hex, :telemetry_poller, "1.3.0", "d5c46420126b5ac2d72bc6580fb4f537d35e851cc0f8dbd571acf6d6e10f5ec7", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "51f18bed7128544a50f75897db9974436ea9bfba560420b646af27a9a9b35211"},
|
||||
"text_diff": {:hex, :text_diff, "0.1.0", "1caf3175e11a53a9a139bc9339bd607c47b9e376b073d4571c031913317fecaa", [:mix], [], "hexpm", "d1ffaaecab338e49357b6daa82e435f877e0649041ace7755583a0ea3362dbd7"},
|
||||
"thousand_island": {:hex, :thousand_island, "1.4.3", "2158209580f633be38d43ec4e3ce0a01079592b9657afff9080d5d8ca149a3af", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6e4ce09b0fd761a58594d02814d40f77daff460c48a7354a15ab353bb998ea0b"},
|
||||
"thousand_island": {:hex, :thousand_island, "1.5.0", "f50a213cac97262b6d5ebb85745aa2c00fec1413191e6e66834788d45425cecb", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "708923d40523e43cf99041ab37a0d4b0ec426ac6438fa3716ab23d919eaeb412"},
|
||||
"tidewave": {:hex, :tidewave, "0.5.6", "91f35540b5599640443f1d3a1c6166bf506e202840261a6344e384e8813c1f64", [:mix], [{:circular_buffer, "~> 0.4 or ~> 1.0", [hex: :circular_buffer, repo: "hexpm", optional: false]}, {:igniter, "~> 0.6", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix_live_reload, ">= 1.6.1", [hex: :phoenix_live_reload, repo: "hexpm", optional: true]}, {:plug, "~> 1.17", [hex: :plug, repo: "hexpm", optional: false]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}], "hexpm", "dc82d52b8b6ffc04680544b17cd340c7d4166bb0d63999eb960850526866b533"},
|
||||
"tz": {:hex, :tz, "0.28.1", "717f5ffddfd1e475e2a233e221dc0b4b76c35c4b3650b060c8e3ba29dd6632e9", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:mint, "~> 1.6", [hex: :mint, repo: "hexpm", optional: true]}], "hexpm", "bfdca1aa1902643c6c43b77c1fb0cb3d744fd2f09a8a98405468afdee0848c8a"},
|
||||
"unicode_util_compat": {:hex, :unicode_util_compat, "0.7.1", "a48703a25c170eedadca83b11e88985af08d35f37c6f664d6dcfb106a97782fc", [:rebar3], [], "hexpm", "b3a917854ce3ae233619744ad1e0102e05673136776fb2fa76234f3e03b23642"},
|
||||
"websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"},
|
||||
"websock_adapter": {:hex, :websock_adapter, "0.5.9", "43dc3ba6d89ef5dec5b1d0a39698436a1e856d000d84bf31a3149862b01a287f", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "5534d5c9adad3c18a0f58a9371220d75a803bf0b9a3d87e6fe072faaeed76a08"},
|
||||
"yamerl": {:hex, :yamerl, "0.10.0", "4ff81fee2f1f6a46f1700c0d880b24d193ddb74bd14ef42cb0bcf46e81ef2f8e", [:rebar3], [], "hexpm", "346adb2963f1051dc837a2364e4acf6eb7d80097c0f53cbdc3046ec8ec4b4e6e"},
|
||||
"yaml_elixir": {:hex, :yaml_elixir, "2.12.1", "d74f2d82294651b58dac849c45a82aaea639766797359baff834b64439f6b3f4", [:mix], [{:yamerl, "~> 0.10", [hex: :yamerl, repo: "hexpm", optional: false]}], "hexpm", "d9ac16563c737d55f9bfeed7627489156b91268a3a21cd55c54eb2e335207fed"},
|
||||
"yaml_elixir": {:hex, :yaml_elixir, "2.12.2", "9dd1330fb4cd9a36a7b0f502e5b12486eff632792ee4a5f0eba52a4d4ec32c9c", [:mix], [{:yamerl, "~> 0.10", [hex: :yamerl, repo: "hexpm", optional: false]}], "hexpm", "e7c1b10122f973e6558462d51c39026ba0e14afbc6745318e990ea82cfe9e159"},
|
||||
"ymlr": {:hex, :ymlr, "5.1.5", "0b9207c7940be3f2bc29b77cd55109d5aa2f4dcde6575942017335769e6f5628", [:mix], [], "hexpm", "7030cb240c46850caeb3b01be745307632be319b15f03083136f6251f49b516d"},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -84,6 +84,7 @@ msgstr "Über Mitgliedsbeitragsarten"
|
|||
msgid "Accounting-Software (Vereinfacht) Integration"
|
||||
msgstr "Buchhaltungs-Software (Vereinfacht) Integration"
|
||||
|
||||
#: lib/mv_web/components/bulk_actions_dropdown.ex
|
||||
#: lib/mv_web/components/core_components.ex
|
||||
#: lib/mv_web/live/group_live/show.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
|
|
@ -366,11 +367,6 @@ msgstr "Mitglied werden"
|
|||
msgid "By submitting your application you will receive an email with a confirmation link. Once you have confirmed your email address, your application will be reviewed."
|
||||
msgstr "Mit Absenden deines Antrags erhältst du eine Mail mit einem Bestätigungslink. Sobald du deine Mail-Adresse bestätigt hast, wird dein Antrag geprüft."
|
||||
|
||||
#: lib/mv_web/components/export_dropdown.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "CSV"
|
||||
msgstr "CSV"
|
||||
|
||||
#: lib/mv_web/live/import_live/components.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "CSV File"
|
||||
|
|
@ -670,16 +666,11 @@ msgid_plural "Copied %{count} email addresses to clipboard"
|
|||
msgstr[0] "%{count} E-Mail-Adresse in die Zwischenablage kopiert"
|
||||
msgstr[1] "%{count} E-Mail-Adressen in die Zwischenablage kopiert"
|
||||
|
||||
#: lib/mv_web/live/member_live/index.html.heex
|
||||
#: lib/mv_web/components/bulk_actions_dropdown.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Copy email addresses"
|
||||
msgstr "E-Mail-Adressen kopieren"
|
||||
|
||||
#: lib/mv_web/live/member_live/index.html.heex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Copy email addresses of selected members"
|
||||
msgstr "E-Mail-Adressen der ausgewählten Mitglieder kopieren"
|
||||
|
||||
#: lib/mv_web/live/datafields_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Could not load data fields. Please check your permissions."
|
||||
|
|
@ -1198,22 +1189,17 @@ msgstr "Austritte"
|
|||
msgid "Expense"
|
||||
msgstr "Ausgabe"
|
||||
|
||||
#: lib/mv_web/components/export_dropdown.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Export"
|
||||
msgstr "Export"
|
||||
|
||||
#: lib/mv_web/controllers/member_pdf_export_controller.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Export contains %{count} rows, maximum is %{max}"
|
||||
msgstr "Export enthält %{count} Zeilen, Maximum ist %{max}."
|
||||
|
||||
#: lib/mv_web/components/export_dropdown.ex
|
||||
#: lib/mv_web/components/bulk_actions_dropdown.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Export members to CSV"
|
||||
msgstr "Mitglieder als CSV exportieren"
|
||||
|
||||
#: lib/mv_web/components/export_dropdown.ex
|
||||
#: lib/mv_web/components/bulk_actions_dropdown.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Export members to PDF"
|
||||
msgstr "Mitglieder als PDF exportieren"
|
||||
|
|
@ -2206,11 +2192,6 @@ msgstr "Kein Mitglied verknüpft"
|
|||
msgid "No members in this group"
|
||||
msgstr "Keine Mitglieder in dieser Gruppe"
|
||||
|
||||
#: lib/mv_web/live/member_live/index.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "No members selected"
|
||||
msgstr "Keine Mitglieder ausgewählt"
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "No membership fee cycles found. Cycles will be generated automatically when a membership fee type is assigned."
|
||||
|
|
@ -2364,12 +2345,7 @@ msgstr "Nur Administrator*innen oder die verknüpfte*n Benutzer*in(nen) können
|
|||
msgid "Only possible if no members are assigned to this type."
|
||||
msgstr "Nur möglich, wenn diesem Typ keine Mitglieder zugewiesen sind."
|
||||
|
||||
#: lib/mv_web/live/member_live/index.html.heex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Open email program with BCC recipients"
|
||||
msgstr "E-Mail-Programm mit BCC-Empfänger*innen öffnen"
|
||||
|
||||
#: lib/mv_web/live/member_live/index.html.heex
|
||||
#: lib/mv_web/components/bulk_actions_dropdown.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Open in email program"
|
||||
msgstr "Im E-Mail-Programm öffnen"
|
||||
|
|
@ -2395,11 +2371,6 @@ msgstr "Optional"
|
|||
msgid "Options"
|
||||
msgstr "Optionen"
|
||||
|
||||
#: lib/mv_web/components/export_dropdown.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "PDF"
|
||||
msgstr "PDF"
|
||||
|
||||
#: lib/mv/membership/members_pdf.ex
|
||||
#: lib/mv_web/live/components/member_filter_component.ex
|
||||
#: lib/mv_web/live/member_live/show.ex
|
||||
|
|
@ -3574,7 +3545,7 @@ msgstr "Dein Passwort wurde erfolgreich zurückgesetzt"
|
|||
msgid "admin - Unrestricted access"
|
||||
msgstr "admin – Uneingeschränkter Zugriff"
|
||||
|
||||
#: lib/mv_web/components/export_dropdown.ex
|
||||
#: lib/mv_web/components/bulk_actions_dropdown.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "all"
|
||||
msgstr "alle"
|
||||
|
|
@ -3967,6 +3938,21 @@ msgstr "Zeitraum"
|
|||
msgid "To"
|
||||
msgstr "Bis"
|
||||
|
||||
#: lib/mv_web/live/member_live/show.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Join form:"
|
||||
msgstr "Beitrittsformular:"
|
||||
|
||||
#: lib/mv_web/live/custom_field_live/form_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Description for join form"
|
||||
msgstr "Beschreibung für das Beitrittsformular"
|
||||
|
||||
#: lib/mv_web/live/custom_field_live/form_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "You can add links: full addresses (https://…) or as [link text](https://…)."
|
||||
msgstr "Du kannst Links einfügen: ganze Adressen (https://…) oder als [Linktext](https://…)."
|
||||
|
||||
#: lib/mv/membership/import/member_csv.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Fee type '%{name}' not found; using the default fee type."
|
||||
|
|
@ -4076,3 +4062,53 @@ msgstr "Zeile 2"
|
|||
#, elixir-autogen, elixir-format
|
||||
msgid "Row 3"
|
||||
msgstr "Zeile 3"
|
||||
|
||||
#: lib/mv_web/components/bulk_actions_dropdown.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Export to CSV"
|
||||
msgstr "Mitglieder als CSV exportieren"
|
||||
|
||||
#: lib/mv_web/components/bulk_actions_dropdown.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Export to PDF"
|
||||
msgstr "Mitglieder als PDF exportieren"
|
||||
|
||||
#: lib/mv_web/components/bulk_actions_dropdown.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Too many recipients for this function. Copy the addresses or export the list."
|
||||
msgstr "Zu viele Empfänger für diese Funktion. Kopiere die Adressen oder exportiere die Liste."
|
||||
|
||||
#: lib/mv_web/components/bulk_actions_dropdown.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "filtered"
|
||||
msgstr "gefiltert"
|
||||
|
||||
#~ #: lib/mv_web/components/export_dropdown.ex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "CSV"
|
||||
#~ msgstr "CSV"
|
||||
|
||||
#~ #: lib/mv_web/live/member_live/index.html.heex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "Copy email addresses of selected members"
|
||||
#~ msgstr "E-Mail-Adressen der ausgewählten Mitglieder kopieren"
|
||||
|
||||
#~ #: lib/mv_web/components/export_dropdown.ex
|
||||
#~ #, elixir-autogen, elixir-format, fuzzy
|
||||
#~ msgid "Export"
|
||||
#~ msgstr "Export"
|
||||
|
||||
#~ #: lib/mv_web/live/member_live/index.ex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "No members selected"
|
||||
#~ msgstr "Keine Mitglieder ausgewählt"
|
||||
|
||||
#~ #: lib/mv_web/live/member_live/index.html.heex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "Open email program with BCC recipients"
|
||||
#~ msgstr "E-Mail-Programm mit BCC-Empfänger*innen öffnen"
|
||||
|
||||
#~ #: lib/mv_web/components/export_dropdown.ex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "PDF"
|
||||
#~ msgstr "PDF"
|
||||
|
|
|
|||
|
|
@ -85,6 +85,7 @@ msgstr ""
|
|||
msgid "Accounting-Software (Vereinfacht) Integration"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/bulk_actions_dropdown.ex
|
||||
#: lib/mv_web/components/core_components.ex
|
||||
#: lib/mv_web/live/group_live/show.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
|
|
@ -367,11 +368,6 @@ msgstr ""
|
|||
msgid "By submitting your application you will receive an email with a confirmation link. Once you have confirmed your email address, your application will be reviewed."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/export_dropdown.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "CSV"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/import_live/components.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "CSV File"
|
||||
|
|
@ -671,16 +667,11 @@ msgid_plural "Copied %{count} email addresses to clipboard"
|
|||
msgstr[0] ""
|
||||
msgstr[1] ""
|
||||
|
||||
#: lib/mv_web/live/member_live/index.html.heex
|
||||
#: lib/mv_web/components/bulk_actions_dropdown.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Copy email addresses"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/index.html.heex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Copy email addresses of selected members"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/datafields_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Could not load data fields. Please check your permissions."
|
||||
|
|
@ -1199,22 +1190,17 @@ msgstr ""
|
|||
msgid "Expense"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/export_dropdown.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Export"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/controllers/member_pdf_export_controller.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Export contains %{count} rows, maximum is %{max}"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/export_dropdown.ex
|
||||
#: lib/mv_web/components/bulk_actions_dropdown.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Export members to CSV"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/export_dropdown.ex
|
||||
#: lib/mv_web/components/bulk_actions_dropdown.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Export members to PDF"
|
||||
msgstr ""
|
||||
|
|
@ -2207,11 +2193,6 @@ msgstr ""
|
|||
msgid "No members in this group"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/index.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "No members selected"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "No membership fee cycles found. Cycles will be generated automatically when a membership fee type is assigned."
|
||||
|
|
@ -2365,12 +2346,7 @@ msgstr ""
|
|||
msgid "Only possible if no members are assigned to this type."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/index.html.heex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Open email program with BCC recipients"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/index.html.heex
|
||||
#: lib/mv_web/components/bulk_actions_dropdown.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Open in email program"
|
||||
msgstr ""
|
||||
|
|
@ -2396,11 +2372,6 @@ msgstr ""
|
|||
msgid "Options"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/export_dropdown.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "PDF"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv/membership/members_pdf.ex
|
||||
#: lib/mv_web/live/components/member_filter_component.ex
|
||||
#: lib/mv_web/live/member_live/show.ex
|
||||
|
|
@ -3574,7 +3545,7 @@ msgstr ""
|
|||
msgid "admin - Unrestricted access"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/export_dropdown.ex
|
||||
#: lib/mv_web/components/bulk_actions_dropdown.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "all"
|
||||
msgstr ""
|
||||
|
|
@ -3967,6 +3938,21 @@ msgstr ""
|
|||
msgid "To"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Join form:"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/custom_field_live/form_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Description for join form"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/custom_field_live/form_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "You can add links: full addresses (https://…) or as [link text](https://…)."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv/membership/import/member_csv.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Fee type '%{name}' not found; using the default fee type."
|
||||
|
|
@ -4076,3 +4062,23 @@ msgstr ""
|
|||
#, elixir-autogen, elixir-format
|
||||
msgid "Row 3"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/bulk_actions_dropdown.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Export to CSV"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/bulk_actions_dropdown.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Export to PDF"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/bulk_actions_dropdown.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Too many recipients for this function. Copy the addresses or export the list."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/bulk_actions_dropdown.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "filtered"
|
||||
msgstr ""
|
||||
|
|
|
|||
|
|
@ -85,6 +85,7 @@ msgstr ""
|
|||
msgid "Accounting-Software (Vereinfacht) Integration"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/bulk_actions_dropdown.ex
|
||||
#: lib/mv_web/components/core_components.ex
|
||||
#: lib/mv_web/live/group_live/show.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
|
|
@ -367,11 +368,6 @@ msgstr ""
|
|||
msgid "By submitting your application you will receive an email with a confirmation link. Once you have confirmed your email address, your application will be reviewed."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/export_dropdown.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "CSV"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/import_live/components.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "CSV File"
|
||||
|
|
@ -671,16 +667,11 @@ msgid_plural "Copied %{count} email addresses to clipboard"
|
|||
msgstr[0] ""
|
||||
msgstr[1] ""
|
||||
|
||||
#: lib/mv_web/live/member_live/index.html.heex
|
||||
#: lib/mv_web/components/bulk_actions_dropdown.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Copy email addresses"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/index.html.heex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Copy email addresses of selected members"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/datafields_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Could not load data fields. Please check your permissions."
|
||||
|
|
@ -1199,22 +1190,17 @@ msgstr ""
|
|||
msgid "Expense"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/export_dropdown.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Export"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/controllers/member_pdf_export_controller.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Export contains %{count} rows, maximum is %{max}"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/export_dropdown.ex
|
||||
#: lib/mv_web/components/bulk_actions_dropdown.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Export members to CSV"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/export_dropdown.ex
|
||||
#: lib/mv_web/components/bulk_actions_dropdown.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Export members to PDF"
|
||||
msgstr ""
|
||||
|
|
@ -2207,11 +2193,6 @@ msgstr ""
|
|||
msgid "No members in this group"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/index.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "No members selected"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "No membership fee cycles found. Cycles will be generated automatically when a membership fee type is assigned."
|
||||
|
|
@ -2365,12 +2346,7 @@ msgstr ""
|
|||
msgid "Only possible if no members are assigned to this type."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/index.html.heex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Open email program with BCC recipients"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/index.html.heex
|
||||
#: lib/mv_web/components/bulk_actions_dropdown.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Open in email program"
|
||||
msgstr ""
|
||||
|
|
@ -2396,11 +2372,6 @@ msgstr ""
|
|||
msgid "Options"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/export_dropdown.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "PDF"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv/membership/members_pdf.ex
|
||||
#: lib/mv_web/live/components/member_filter_component.ex
|
||||
#: lib/mv_web/live/member_live/show.ex
|
||||
|
|
@ -3574,7 +3545,7 @@ msgstr ""
|
|||
msgid "admin - Unrestricted access"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/export_dropdown.ex
|
||||
#: lib/mv_web/components/bulk_actions_dropdown.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "all"
|
||||
msgstr ""
|
||||
|
|
@ -3967,6 +3938,21 @@ msgstr ""
|
|||
msgid "To"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Join form:"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/custom_field_live/form_component.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Description for join form"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/custom_field_live/form_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "You can add links: full addresses (https://…) or as [link text](https://…)."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv/membership/import/member_csv.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Fee type '%{name}' not found; using the default fee type."
|
||||
|
|
@ -4076,3 +4062,53 @@ msgstr ""
|
|||
#, elixir-autogen, elixir-format
|
||||
msgid "Row 3"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/bulk_actions_dropdown.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Export to CSV"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/bulk_actions_dropdown.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Export to PDF"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/bulk_actions_dropdown.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Too many recipients for this function. Copy the addresses or export the list."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/bulk_actions_dropdown.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "filtered"
|
||||
msgstr ""
|
||||
|
||||
#~ #: lib/mv_web/components/export_dropdown.ex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "CSV"
|
||||
#~ msgstr ""
|
||||
|
||||
#~ #: lib/mv_web/live/member_live/index.html.heex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "Copy email addresses of selected members"
|
||||
#~ msgstr ""
|
||||
|
||||
#~ #: lib/mv_web/components/export_dropdown.ex
|
||||
#~ #, elixir-autogen, elixir-format, fuzzy
|
||||
#~ msgid "Export"
|
||||
#~ msgstr ""
|
||||
|
||||
#~ #: lib/mv_web/live/member_live/index.ex
|
||||
#~ #, elixir-autogen, elixir-format, fuzzy
|
||||
#~ msgid "No members selected"
|
||||
#~ msgstr ""
|
||||
|
||||
#~ #: lib/mv_web/live/member_live/index.html.heex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "Open email program with BCC recipients"
|
||||
#~ msgstr ""
|
||||
|
||||
#~ #: lib/mv_web/components/export_dropdown.ex
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "PDF"
|
||||
#~ msgstr ""
|
||||
|
|
|
|||
|
|
@ -0,0 +1,21 @@
|
|||
defmodule Mv.Repo.Migrations.AddJoinDescriptionToCustomFields do
|
||||
@moduledoc """
|
||||
Updates resources based on their most recent snapshots.
|
||||
|
||||
This file was autogenerated with `mix ash_postgres.generate_migrations`
|
||||
"""
|
||||
|
||||
use Ecto.Migration
|
||||
|
||||
def up do
|
||||
alter table(:custom_fields) do
|
||||
add :join_description, :text
|
||||
end
|
||||
end
|
||||
|
||||
def down do
|
||||
alter table(:custom_fields) do
|
||||
remove :join_description
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -81,9 +81,11 @@ custom_field_configs = [
|
|||
show_in_overview: true
|
||||
},
|
||||
%{
|
||||
name: "Datenschutzerklärung akzeptiert",
|
||||
name: "DSGVO",
|
||||
value_type: :boolean,
|
||||
description: "Angabe, ob Datenschutzerklärung akzeptiert wurde",
|
||||
description: "Angabe, ob die Datenschutzerklärung akzeptiert wurde",
|
||||
join_description:
|
||||
"Ich habe die [Datenschutzerklärung](https://example.org/datenschutz) gelesen und akzeptiere sie.",
|
||||
required: false,
|
||||
show_in_overview: false
|
||||
},
|
||||
|
|
@ -302,11 +304,15 @@ case Membership.get_settings() do
|
|||
ArgumentError -> Map.has_key?(vis, k)
|
||||
end
|
||||
end
|
||||
|
||||
merged =
|
||||
Enum.reduce(default_hidden_in_overview, visibility_config, fn {key, val}, vis ->
|
||||
if has_key.(vis, key), do: vis, else: Map.put(vis, key, val)
|
||||
end)
|
||||
if merged != visibility_config, do: Map.put(acc, :member_field_visibility, merged), else: acc
|
||||
|
||||
if merged != visibility_config,
|
||||
do: Map.put(acc, :member_field_visibility, merged),
|
||||
else: acc
|
||||
end)
|
||||
|
||||
if map_size(updates) > 0 do
|
||||
|
|
@ -332,9 +338,7 @@ IO.puts(
|
|||
" - Fee types: 5 (Standard, Ermäßigt, Unterstützer, Fördermitglied, Probemitgliedschaft)"
|
||||
)
|
||||
|
||||
IO.puts(
|
||||
" - Custom fields: 6 (Geburtsdatum shown in overview; others hidden by default)"
|
||||
)
|
||||
IO.puts(" - Custom fields: 6 (Geburtsdatum shown in overview; others hidden by default)")
|
||||
|
||||
IO.puts(" - Roles: 5 (Mitglied, Vorstand, Kassenwart, Buchhaltung, Admin)")
|
||||
IO.puts(" - Default fee type: Standard (120€ yearly)")
|
||||
|
|
|
|||
|
|
@ -431,15 +431,16 @@ end)
|
|||
all_custom_fields = Ash.read!(Membership.CustomField, actor: admin_user_with_role)
|
||||
find_field = fn name -> Enum.find(all_custom_fields, &(&1.name == name)) end
|
||||
|
||||
# 16 members with 4–6 custom field values each (Geburtsdatum, Datenschutz, SEPA, Rechnungs-E-Mail, IBAN, Stunden)
|
||||
# 16 members with 4–6 custom field values each (Geburtsdatum, DSGVO, SEPA, Rechnungs-E-Mail, IBAN, Stunden)
|
||||
custom_value_assignments =
|
||||
Enum.map(1..16, fn n ->
|
||||
email = "mitglied#{n}@example.de"
|
||||
# Vary birth dates and values per index
|
||||
base_date = Date.add(~D[1970-01-01], 365 * (n + 10) + rem(n * 31, 365))
|
||||
|
||||
values = [
|
||||
{"Geburtsdatum", %{"_union_type" => "date", "_union_value" => base_date}},
|
||||
{"Datenschutzerklärung akzeptiert",
|
||||
{"DSGVO",
|
||||
%{"_union_type" => "boolean", "_union_value" => n in [1, 2, 3, 5, 7, 9, 11, 13, 15]}},
|
||||
{"SEPA-Mandat", %{"_union_type" => "boolean", "_union_value" => rem(n, 3) != 0}},
|
||||
{"Rechnungs-E-Mail",
|
||||
|
|
@ -448,10 +449,12 @@ custom_value_assignments =
|
|||
%{
|
||||
"_union_type" => "string",
|
||||
"_union_value" =>
|
||||
"DE8937040044#{String.pad_leading(to_string(rem(532013000 + n, 1_000_000_000)), 10, "0")}"
|
||||
"DE8937040044#{String.pad_leading(to_string(rem(532_013_000 + n, 1_000_000_000)), 10, "0")}"
|
||||
}},
|
||||
{"Stunden ehrenamtlich", %{"_union_type" => "integer", "_union_value" => 5 + rem(n * 3, 25)}}
|
||||
{"Stunden ehrenamtlich",
|
||||
%{"_union_type" => "integer", "_union_value" => 5 + rem(n * 3, 25)}}
|
||||
]
|
||||
|
||||
# Drop 0–2 fields per member so not all have 6 (still ~80% overall filled)
|
||||
drop_count = rem(n, 3)
|
||||
{email, Enum.take(values, 6 - drop_count)}
|
||||
|
|
@ -502,19 +505,36 @@ case Membership.get_settings() do
|
|||
Membership.update_settings(settings, %{
|
||||
join_form_enabled: true,
|
||||
join_form_field_ids: settings.join_form_field_ids || default_join_form_field_ids,
|
||||
join_form_field_required: settings.join_form_field_required || default_join_form_field_required
|
||||
join_form_field_required:
|
||||
settings.join_form_field_required || default_join_form_field_required
|
||||
})
|
||||
end
|
||||
|
||||
_ ->
|
||||
:ok
|
||||
end
|
||||
|
||||
# Membership applications (join requests) for UI testing: 4 submitted, 1 with extra form_data
|
||||
join_request_configs = [
|
||||
%{email: "antrag1@example.de", first_name: "Sandra", last_name: "Meier", form_data: %{"city" => "Berlin"}},
|
||||
%{
|
||||
email: "antrag1@example.de",
|
||||
first_name: "Sandra",
|
||||
last_name: "Meier",
|
||||
form_data: %{"city" => "Berlin"}
|
||||
},
|
||||
%{email: "antrag2@example.de", first_name: "Thomas", last_name: "Bauer", form_data: %{}},
|
||||
%{email: "antrag3@example.de", first_name: "Julia", last_name: "Krause", form_data: %{"city" => "Hamburg", "notes" => "Interesse an Jugendgruppe"}},
|
||||
%{email: "antrag4@example.de", first_name: "Michael", last_name: "Schmitt", form_data: %{"city" => "München"}}
|
||||
%{
|
||||
email: "antrag3@example.de",
|
||||
first_name: "Julia",
|
||||
last_name: "Krause",
|
||||
form_data: %{"city" => "Hamburg", "notes" => "Interesse an Jugendgruppe"}
|
||||
},
|
||||
%{
|
||||
email: "antrag4@example.de",
|
||||
first_name: "Michael",
|
||||
last_name: "Schmitt",
|
||||
form_data: %{"city" => "München"}
|
||||
}
|
||||
]
|
||||
|
||||
for config <- join_request_configs do
|
||||
|
|
@ -532,8 +552,15 @@ for config <- join_request_configs do
|
|||
end
|
||||
|
||||
IO.puts("✅ Dev seeds completed.")
|
||||
IO.puts(" - Members: 20 with country (mostly Deutschland, 1 Österreich, 1 Schweiz), fee types distributed, 5 with exit date")
|
||||
IO.puts(" - Test users: 4 linked to mitglied1–4 with roles Mitglied, Vorstand, Kassenwart, Buchhaltung")
|
||||
|
||||
IO.puts(
|
||||
" - Members: 20 with country (mostly Deutschland, 1 Österreich, 1 Schweiz), fee types distributed, 5 with exit date"
|
||||
)
|
||||
|
||||
IO.puts(
|
||||
" - Test users: 4 linked to mitglied1–4 with roles Mitglied, Vorstand, Kassenwart, Buchhaltung"
|
||||
)
|
||||
|
||||
IO.puts(" - Groups: Vorstand, Jugend, Newsletter (with assignments)")
|
||||
IO.puts(" - Custom field values: ~80% filled (16 members, 4–6 fields each)")
|
||||
IO.puts(" - Join form enabled; 4 membership applications (join requests) for UI testing")
|
||||
|
|
|
|||
145
priv/resource_snapshots/repo/custom_fields/20260603000204.json
Normal file
145
priv/resource_snapshots/repo/custom_fields/20260603000204.json
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
{
|
||||
"attributes": [
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "fragment(\"gen_random_uuid()\")",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": true,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "id",
|
||||
"type": "uuid"
|
||||
},
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "name",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "slug",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "value_type",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"allow_nil?": true,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "description",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"allow_nil?": true,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "join_description",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "false",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "required",
|
||||
"type": "boolean"
|
||||
},
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "true",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "show_in_overview",
|
||||
"type": "boolean"
|
||||
}
|
||||
],
|
||||
"base_filter": null,
|
||||
"check_constraints": [],
|
||||
"create_table_options": null,
|
||||
"custom_indexes": [],
|
||||
"custom_statements": [],
|
||||
"has_create_action": true,
|
||||
"hash": "2600667D140A2A846F9A848ACEFCADA1F1206950B38EF407B0BB13816E508A2A",
|
||||
"identities": [
|
||||
{
|
||||
"all_tenants?": false,
|
||||
"base_filter": null,
|
||||
"index_name": "custom_fields_unique_name_index",
|
||||
"keys": [
|
||||
{
|
||||
"type": "atom",
|
||||
"value": "name"
|
||||
}
|
||||
],
|
||||
"name": "unique_name",
|
||||
"nils_distinct?": true,
|
||||
"where": null
|
||||
},
|
||||
{
|
||||
"all_tenants?": false,
|
||||
"base_filter": null,
|
||||
"index_name": "custom_fields_unique_slug_index",
|
||||
"keys": [
|
||||
{
|
||||
"type": "atom",
|
||||
"value": "slug"
|
||||
}
|
||||
],
|
||||
"name": "unique_slug",
|
||||
"nils_distinct?": true,
|
||||
"where": null
|
||||
}
|
||||
],
|
||||
"multitenancy": {
|
||||
"attribute": null,
|
||||
"global": null,
|
||||
"strategy": null
|
||||
},
|
||||
"repo": "Elixir.Mv.Repo",
|
||||
"schema": null,
|
||||
"table": "custom_fields"
|
||||
}
|
||||
101
publiccode.yml
Normal file
101
publiccode.yml
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
publiccodeYmlVersion: "0.2"
|
||||
name: Mila
|
||||
url: "https://git.local-it.org/local-it/mitgliederverwaltung"
|
||||
landingURL: "https://local-it.org"
|
||||
softwareVersion: "1.2.0"
|
||||
releaseDate: "2026-05-08"
|
||||
developmentStatus: beta
|
||||
logo: logo.png
|
||||
platforms:
|
||||
- web
|
||||
softwareType: standalone/web
|
||||
categories:
|
||||
- contact-management
|
||||
- crm
|
||||
- billing-and-invoicing
|
||||
intendedAudience:
|
||||
scope:
|
||||
- society
|
||||
dependsOn:
|
||||
open:
|
||||
- name: PostgreSQL
|
||||
versionMin: "18"
|
||||
maintenance:
|
||||
type: internal
|
||||
contacts:
|
||||
- name: "Local-IT e.V."
|
||||
email: "info@local-it.org"
|
||||
legal:
|
||||
license: AGPL-3.0-only
|
||||
mainCopyrightOwner: "Local-IT e.V."
|
||||
repoOwner: "Local-IT e.V."
|
||||
localisation:
|
||||
localisationReady: true
|
||||
availableLanguages:
|
||||
- de
|
||||
- en
|
||||
description:
|
||||
de:
|
||||
genericName: Mitgliederverwaltung
|
||||
shortDescription: >-
|
||||
Einfache, barrierearme und freie Mitgliederverwaltung
|
||||
für kleine und mittlere Vereine.
|
||||
longDescription: >
|
||||
**Mila** ist eine freie und quelloffene Mitgliederverwaltung, die auf
|
||||
die realen Bedürfnisse von Vereinen ausgerichtet ist. Statt
|
||||
überladener Funktionen oder teurer Lizenzen setzt Mila auf
|
||||
Bedienbarkeit, Barrierefreiheit und DSGVO-Konformität. Vereine
|
||||
verwalten ihre Mitgliederdaten, behalten Mitgliedsbeiträge und
|
||||
Zahlungsstatus im Blick und passen erfasste Datenfelder, Rollen und
|
||||
Berechtigungen flexibel an ihre Struktur an. Die Anwendung ist
|
||||
self-hosting-freundlich, mehrsprachig (Deutsch und Englisch) und
|
||||
unterstützt Single Sign-on über OIDC sowie Self-Service und
|
||||
Online-Aufnahmeanträge für Mitglieder.
|
||||
documentation: "https://wiki.local-it.org/s/mila-user-dokumentation"
|
||||
features:
|
||||
- Mitgliederdaten komfortabel verwalten
|
||||
- Mitglieder in Gruppen organisieren
|
||||
- Mitgliedsbeiträge und Zahlungsstatus verfolgen
|
||||
- Volltextsuche mit unscharfer Suche (Fuzzy-Matching)
|
||||
- Rollen und Berechtigungen (RBAC)
|
||||
- Anpassbare Datenfelder pro Verein
|
||||
- Single Sign-on über OIDC (Authentik, Rauthy, Keycloak)
|
||||
- Self-Service und Online-Aufnahmeanträge
|
||||
- Mitglieder per CSV importieren (mit Vorschau und Vorlagen)
|
||||
- Anbindung an die Buchhaltungssoftware Vereinfacht
|
||||
screenshots:
|
||||
- .opencode/screenshots/01_mitglieder.png
|
||||
- .opencode/screenshots/02_statistik.png
|
||||
- .opencode/screenshots/03_beitraege.png
|
||||
- .opencode/screenshots/04_aufnahmeantraege.png
|
||||
en:
|
||||
genericName: Membership management
|
||||
shortDescription: >-
|
||||
Simple, accessible and free membership management
|
||||
for small and mid-sized clubs.
|
||||
longDescription: >
|
||||
**Mila** is a free and open-source membership management tool designed
|
||||
for the real needs of clubs and associations. Instead of feature
|
||||
overload or expensive licences, Mila focuses on usability,
|
||||
accessibility and GDPR compliance. Clubs manage their member data,
|
||||
keep track of membership fees and payment status, and adapt the
|
||||
collected data fields, roles and permissions to their own structure.
|
||||
The application is self-hosting friendly, multilingual (German and
|
||||
English) and supports single sign-on via OIDC as well as member
|
||||
self-service and online membership applications.
|
||||
features:
|
||||
- Manage member data with ease
|
||||
- Organize members into groups
|
||||
- Track membership fees and payment status
|
||||
- Full-text search with fuzzy matching
|
||||
- Roles and permissions (RBAC)
|
||||
- Custom data fields per club
|
||||
- Single sign-on via OIDC (Authentik, Rauthy, Keycloak)
|
||||
- Member self-service and online application
|
||||
- Import members via CSV (with preview and templates)
|
||||
- Integration with the Vereinfacht accounting software
|
||||
screenshots:
|
||||
- .opencode/screenshots/01_mitglieder.png
|
||||
- .opencode/screenshots/02_statistik.png
|
||||
- .opencode/screenshots/03_beitraege.png
|
||||
- .opencode/screenshots/04_aufnahmeantraege.png
|
||||
|
|
@ -159,6 +159,61 @@ defmodule Mv.Membership.CustomFieldValidationTest do
|
|||
end
|
||||
end
|
||||
|
||||
describe "join_description" do
|
||||
test "persists join_description when set", %{actor: actor} do
|
||||
assert {:ok, custom_field} =
|
||||
CustomField
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
name: "dsgvo_field",
|
||||
value_type: :boolean,
|
||||
join_description: "hereby I confirm the GDPR"
|
||||
})
|
||||
|> Ash.create(actor: actor)
|
||||
|
||||
assert custom_field.join_description == "hereby I confirm the GDPR"
|
||||
end
|
||||
|
||||
test "defaults to nil when not given", %{actor: actor} do
|
||||
assert {:ok, custom_field} =
|
||||
CustomField
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
name: "no_join_desc",
|
||||
value_type: :boolean
|
||||
})
|
||||
|> Ash.create(actor: actor)
|
||||
|
||||
assert custom_field.join_description == nil
|
||||
end
|
||||
|
||||
test "rejects join_description longer than 1000 characters", %{actor: actor} do
|
||||
assert {:error, changeset} =
|
||||
CustomField
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
name: "too_long_join_desc",
|
||||
value_type: :boolean,
|
||||
join_description: String.duplicate("a", 1001)
|
||||
})
|
||||
|> Ash.create(actor: actor)
|
||||
|
||||
assert [%{field: :join_description, message: message}] = changeset.errors
|
||||
assert message =~ "max" or message =~ "length" or message =~ "1000"
|
||||
end
|
||||
|
||||
test "is writable via the update action", %{actor: actor} do
|
||||
{:ok, custom_field} =
|
||||
CustomField
|
||||
|> Ash.Changeset.for_create(:create, %{name: "updatable", value_type: :boolean})
|
||||
|> Ash.create(actor: actor)
|
||||
|
||||
assert {:ok, updated} =
|
||||
custom_field
|
||||
|> Ash.Changeset.for_update(:update, %{join_description: "Accept the GDPR"})
|
||||
|> Ash.update(actor: actor)
|
||||
|
||||
assert updated.join_description == "Accept the GDPR"
|
||||
end
|
||||
end
|
||||
|
||||
describe "name uniqueness" do
|
||||
test "rejects duplicate names", %{actor: actor} do
|
||||
assert {:ok, _} =
|
||||
|
|
|
|||
155
test/mv_web/components/bulk_actions_dropdown_test.exs
Normal file
155
test/mv_web/components/bulk_actions_dropdown_test.exs
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
defmodule MvWeb.Components.BulkActionsDropdownTest do
|
||||
use MvWeb.ConnCase, async: true
|
||||
import Phoenix.LiveViewTest
|
||||
|
||||
alias MvWeb.Components.BulkActionsDropdown
|
||||
|
||||
defp render_open(assigns) do
|
||||
base = %{
|
||||
id: "bulk-actions-dropdown",
|
||||
open: true,
|
||||
export_payload_json: ~s({"selected_ids":[]}),
|
||||
selected_count: 0,
|
||||
scope: :all,
|
||||
mailto_bcc: "a%40example.com",
|
||||
recipient_count: 1,
|
||||
mailto_disabled?: false
|
||||
}
|
||||
|
||||
render_component(BulkActionsDropdown, Map.merge(base, assigns))
|
||||
end
|
||||
|
||||
defp scope_badge(html) do
|
||||
html
|
||||
|> LazyHTML.from_fragment()
|
||||
|> LazyHTML.query(~s([data-testid="bulk-actions-scope-badge"]))
|
||||
end
|
||||
|
||||
describe "trigger scope badge" do
|
||||
test "shows an emphasized primary count badge when members are selected" do
|
||||
html =
|
||||
render_component(BulkActionsDropdown, %{id: "d", scope: :selection, selected_count: 3})
|
||||
|
||||
badge = scope_badge(html)
|
||||
assert badge |> LazyHTML.text() |> String.trim() == "3"
|
||||
classes = badge |> LazyHTML.attribute("class") |> List.first()
|
||||
assert classes =~ "badge-primary"
|
||||
assert classes =~ "badge-sm"
|
||||
# The trigger label itself is just the bare action verb, no parenthetical.
|
||||
assert html =~ "Actions"
|
||||
refute html =~ "(3)"
|
||||
end
|
||||
|
||||
test "shows a muted neutral 'all' badge when nothing selected and no filter" do
|
||||
html = render_component(BulkActionsDropdown, %{id: "d", scope: :all, selected_count: 0})
|
||||
|
||||
badge = scope_badge(html)
|
||||
assert badge |> LazyHTML.text() |> String.trim() == "all"
|
||||
classes = badge |> LazyHTML.attribute("class") |> List.first()
|
||||
assert classes =~ "badge-neutral"
|
||||
assert classes =~ "badge-sm"
|
||||
refute html =~ "(all)"
|
||||
end
|
||||
|
||||
test "shows a muted neutral 'filtered' badge when a filter is active" do
|
||||
html =
|
||||
render_component(BulkActionsDropdown, %{id: "d", scope: :filtered, selected_count: 0})
|
||||
|
||||
badge = scope_badge(html)
|
||||
assert badge |> LazyHTML.text() |> String.trim() == "filtered"
|
||||
classes = badge |> LazyHTML.attribute("class") |> List.first()
|
||||
assert classes =~ "badge-neutral"
|
||||
assert classes =~ "badge-sm"
|
||||
refute html =~ "(filtered)"
|
||||
end
|
||||
end
|
||||
|
||||
describe "trigger affordance" do
|
||||
test "carries a trailing chevron" do
|
||||
html = render_component(BulkActionsDropdown, %{id: "d", scope: :all, selected_count: 0})
|
||||
assert html =~ "hero-chevron-down"
|
||||
end
|
||||
end
|
||||
|
||||
describe "menu item layout" do
|
||||
test "all menu items carry whitespace-nowrap to prevent label text wrapping" do
|
||||
html = render_open(%{})
|
||||
|
||||
doc = LazyHTML.from_fragment(html)
|
||||
|
||||
# Collect all elements with role="menuitem" (both <a> and <button>)
|
||||
items = LazyHTML.query(doc, ~s([role="menuitem"]))
|
||||
classes_list = LazyHTML.attribute(items, "class")
|
||||
|
||||
assert length(classes_list) >= 4,
|
||||
"expected at least 4 menu items, got #{length(classes_list)}"
|
||||
|
||||
for classes <- classes_list do
|
||||
assert classes =~ "whitespace-nowrap",
|
||||
"expected whitespace-nowrap on menu item, got class: #{inspect(classes)}"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "menu items" do
|
||||
test "lists the four bulk actions in order, flattened to one level" do
|
||||
html = render_open(%{})
|
||||
|
||||
mailto = :binary.match(html, "bulk-actions-mailto") |> elem(0)
|
||||
copy = :binary.match(html, "bulk-actions-copy") |> elem(0)
|
||||
csv = :binary.match(html, "export-csv-link") |> elem(0)
|
||||
pdf = :binary.match(html, "export-pdf-link") |> elem(0)
|
||||
|
||||
assert mailto < copy
|
||||
assert copy < csv
|
||||
assert csv < pdf
|
||||
# No nested "Export" submenu trigger — the export items sit at the top level.
|
||||
refute html =~ ~s(data-testid="export-dropdown")
|
||||
end
|
||||
|
||||
test "copy item carries the clipboard hook and an un-targeted copy_emails click" do
|
||||
html = render_open(%{})
|
||||
|
||||
assert html =~ ~s(phx-hook="CopyToClipboard")
|
||||
assert html =~ ~s(phx-click="copy_emails")
|
||||
# The copy click must NOT be targeted at the component, so it reaches the
|
||||
# parent LiveView handler.
|
||||
refute html =~ ~r/phx-click="copy_emails"[^>]*phx-target/
|
||||
end
|
||||
end
|
||||
|
||||
describe "mailto recipient cap" do
|
||||
test "mailto item is enabled below the threshold with a BCC link" do
|
||||
html = render_open(%{mailto_disabled?: false, mailto_bcc: "a%40example.com"})
|
||||
|
||||
assert html =~ ~s(href="mailto:?bcc=a%40example.com")
|
||||
refute html =~ ~r/data-testid="bulk-actions-mailto"[^>]*aria-disabled="true"/s
|
||||
end
|
||||
|
||||
test "mailto item is disabled with the explanatory tooltip at the threshold" do
|
||||
html = render_open(%{mailto_disabled?: true})
|
||||
|
||||
assert html =~ ~s(aria-disabled="true")
|
||||
assert html =~ ~s(tabindex="-1")
|
||||
assert html =~ "Too many recipients for this function"
|
||||
# Disabled mailto must not expose an actionable BCC link.
|
||||
refute html =~ "href=\"mailto:"
|
||||
end
|
||||
end
|
||||
|
||||
describe "export forms" do
|
||||
test "CSV and PDF items keep the CSRF-protected form POST and payload" do
|
||||
payload = ~s({"selected_ids":["x"]})
|
||||
html = render_open(%{export_payload_json: payload})
|
||||
|
||||
assert html =~ ~s(action="/members/export.csv")
|
||||
assert html =~ ~s(action="/members/export.pdf")
|
||||
assert html =~ ~s(name="_csrf_token")
|
||||
assert html =~ ~s(name="payload")
|
||||
# The payload lands HTML-escaped in the hidden input value attribute; both
|
||||
# export forms carry the same payload.
|
||||
escaped = Phoenix.HTML.html_escape(payload) |> Phoenix.HTML.safe_to_string()
|
||||
assert html =~ ~s(name="payload" value="#{escaped}")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -17,5 +17,13 @@ defmodule MvWeb.Components.FieldVisibilityDropdownComponentTest do
|
|||
assert has_element?(view, "button[phx-click='select_all']")
|
||||
assert has_element?(view, "button[phx-click='select_none']")
|
||||
end
|
||||
|
||||
test "trigger carries a trailing chevron affordance", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, _view, html} = live(conn, ~p"/members")
|
||||
|
||||
# The shared dropdown trigger signals "opens a menu" with a trailing chevron.
|
||||
assert html =~ "hero-chevron-down"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -49,6 +49,20 @@ defmodule MvWeb.Components.MemberFilterComponentTest do
|
|||
end
|
||||
|
||||
describe "rendering" do
|
||||
test "trigger carries a trailing chevron affordance", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, _view, html} = live(conn, "/members")
|
||||
|
||||
# Mirror the shared dropdown affordance: a trailing chevron inside the
|
||||
# bespoke filter trigger button.
|
||||
chevron =
|
||||
html
|
||||
|> LazyHTML.from_fragment()
|
||||
|> LazyHTML.query(~s(#member-filter button[aria-haspopup="true"] .hero-chevron-down))
|
||||
|
||||
assert Enum.count(chevron) == 1
|
||||
end
|
||||
|
||||
test "renders boolean custom fields when present", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
boolean_field = create_boolean_custom_field(%{name: "Active Member"})
|
||||
|
|
|
|||
|
|
@ -82,8 +82,10 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
|
|||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, _view, html} = live(conn, "/members?query=&sort_field=email&sort_order=desc")
|
||||
|
||||
# Count occurrences to ensure only one descending icon
|
||||
down_count = html |> String.split("hero-chevron-down ") |> length() |> Kernel.-(1)
|
||||
# Count occurrences to ensure only one descending sort icon. Dropdown
|
||||
# triggers carry their own trailing "hero-chevron-down size-4" chevron, so
|
||||
# the sort-active icon is identified by its bare class (no size-4 suffix).
|
||||
down_count = active_sort_down_count(html)
|
||||
# Should be exactly one chevrondown icon
|
||||
assert down_count == 1
|
||||
end
|
||||
|
|
@ -158,7 +160,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
|
|||
|
||||
# Count active icons (should be exactly 1 - ascending for default sort field)
|
||||
up_count = html_neutral |> String.split("hero-chevron-up ") |> length() |> Kernel.-(1)
|
||||
down_count = html_neutral |> String.split("hero-chevron-down ") |> length() |> Kernel.-(1)
|
||||
down_count = active_sort_down_count(html_neutral)
|
||||
|
||||
assert up_count == 1, "Expected exactly 1 ascending icon, got #{up_count}"
|
||||
assert down_count == 0, "Expected 0 descending icons, got #{down_count}"
|
||||
|
|
@ -167,13 +169,24 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
|
|||
{:ok, _view, html_desc} = live(conn, "/members?sort_field=first_name&sort_order=desc")
|
||||
|
||||
up_count = html_desc |> String.split("hero-chevron-up ") |> length() |> Kernel.-(1)
|
||||
down_count = html_desc |> String.split("hero-chevron-down ") |> length() |> Kernel.-(1)
|
||||
down_count = active_sort_down_count(html_desc)
|
||||
|
||||
assert up_count == 0, "Expected 0 ascending icons, got #{up_count}"
|
||||
assert down_count == 1, "Expected exactly 1 descending icon, got #{down_count}"
|
||||
end
|
||||
end
|
||||
|
||||
# Counts only the descending chevron icons that belong to a sort header. Both
|
||||
# the sort-active icon and the dropdown-trigger chevron render as
|
||||
# "hero-chevron-down size-4", so they are told apart by their containing
|
||||
# button: sort headers carry phx-click="sort", dropdown triggers do not.
|
||||
defp active_sort_down_count(html) do
|
||||
html
|
||||
|> LazyHTML.from_fragment()
|
||||
|> LazyHTML.query(~s(button[phx-click="sort"] .hero-chevron-down))
|
||||
|> Enum.count()
|
||||
end
|
||||
|
||||
describe "accessibility" do
|
||||
test "sets aria-label correctly for unsorted state", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
|
|
|
|||
85
test/mv_web/helpers/join_description_renderer_test.exs
Normal file
85
test/mv_web/helpers/join_description_renderer_test.exs
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
defmodule MvWeb.Helpers.JoinDescriptionRendererTest do
|
||||
@moduledoc """
|
||||
Tests for the join-description renderer that auto-links raw URLs and Markdown
|
||||
links while escaping all other content.
|
||||
"""
|
||||
use ExUnit.Case, async: true
|
||||
use ExUnitProperties
|
||||
|
||||
alias MvWeb.Helpers.JoinDescriptionRenderer
|
||||
|
||||
defp html(value) do
|
||||
value
|
||||
|> JoinDescriptionRenderer.render()
|
||||
|> Phoenix.HTML.safe_to_string()
|
||||
end
|
||||
|
||||
describe "render/1" do
|
||||
test "converts a raw URL to an anchor tag with the standard link class" do
|
||||
result = html("Akzeptiere https://example.com/dsgvo")
|
||||
|
||||
assert result =~ ~s(<a href="https://example.com/dsgvo" class="link link-primary")
|
||||
assert result =~ "https://example.com/dsgvo</a>"
|
||||
assert result =~ "Akzeptiere "
|
||||
end
|
||||
|
||||
test "converts Markdown [text](url) to an anchor tag with the standard link class" do
|
||||
result = html("[Datenschutzerklärung](https://example.com/dsgvo)")
|
||||
|
||||
assert result =~ ~s(<a href="https://example.com/dsgvo" class="link link-primary")
|
||||
assert result =~ ">Datenschutzerklärung</a>"
|
||||
end
|
||||
|
||||
test "returns an empty safe string for nil input" do
|
||||
assert JoinDescriptionRenderer.render(nil) == {:safe, ""}
|
||||
end
|
||||
|
||||
test "escapes arbitrary HTML in non-link text" do
|
||||
result = html("<script>alert(1)</script>")
|
||||
|
||||
refute result =~ "<script>"
|
||||
assert result =~ "<script>"
|
||||
end
|
||||
|
||||
test "does not double-link a Markdown link whose URL also looks like a raw URL" do
|
||||
result = html("[Datenschutz](https://example.com/x)")
|
||||
|
||||
# exactly one anchor, no nested anchor for the inner raw URL
|
||||
assert result |> :binary.matches("<a ") |> length() == 1
|
||||
end
|
||||
end
|
||||
|
||||
describe "property: link-free text" do
|
||||
property "preserves non-link text content as HTML-escaped output" do
|
||||
check all(text <- link_free_string()) do
|
||||
result = html(text)
|
||||
|
||||
# No links emitted, and text content equals the HTML-escaped input.
|
||||
refute result =~ "<a "
|
||||
assert result == Phoenix.HTML.html_escape(text) |> Phoenix.HTML.safe_to_string()
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "property: well-formed Markdown links" do
|
||||
property "renders every [text](https://...) as a single anchor with verbatim text and url" do
|
||||
check all(
|
||||
label <- string(:alphanumeric, min_length: 1),
|
||||
path <- string(:alphanumeric)
|
||||
) do
|
||||
url = "https://example.com/#{path}"
|
||||
result = html("[#{label}](#{url})")
|
||||
|
||||
assert result =~ ~s(<a href="#{url}" class="link link-primary">#{label}</a>)
|
||||
assert result |> :binary.matches("<a ") |> length() == 1
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Printable strings that contain no bare URLs and no Markdown-link opening bracket.
|
||||
defp link_free_string do
|
||||
:printable
|
||||
|> string()
|
||||
|> filter(fn s -> not String.contains?(s, "http") and not String.contains?(s, "[") end)
|
||||
end
|
||||
end
|
||||
102
test/mv_web/live/custom_field_live/form_test.exs
Normal file
102
test/mv_web/live/custom_field_live/form_test.exs
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
defmodule MvWeb.CustomFieldLive.FormTest do
|
||||
@moduledoc """
|
||||
Tests for the CustomFieldLive.FormComponent join_description input.
|
||||
|
||||
Covers that an admin can set and persist a custom field's join_description via
|
||||
the settings edit form.
|
||||
"""
|
||||
use MvWeb.ConnCase, async: true
|
||||
|
||||
import Phoenix.LiveViewTest
|
||||
require Ash.Query
|
||||
|
||||
alias Mv.Membership.CustomField
|
||||
|
||||
setup do
|
||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||
admin_role = Mv.Fixtures.role_fixture("admin")
|
||||
|
||||
{:ok, user} =
|
||||
Mv.Accounts.User
|
||||
|> Ash.Changeset.for_create(:register_with_password, %{
|
||||
email: "admin#{System.unique_integer([:positive])}@mv.local",
|
||||
password: "testpassword123"
|
||||
})
|
||||
|> Ash.create(actor: system_actor)
|
||||
|
||||
{:ok, user} =
|
||||
user
|
||||
|> Ash.Changeset.for_update(:update, %{})
|
||||
|> Ash.Changeset.manage_relationship(:role, admin_role, type: :append_and_remove)
|
||||
|> Ash.update(actor: system_actor)
|
||||
|
||||
user_with_role = Ash.load!(user, :role, domain: Mv.Accounts, actor: system_actor)
|
||||
conn = log_in_user(build_conn(), user_with_role)
|
||||
session = conn.private[:plug_session] || %{}
|
||||
conn = Plug.Test.init_test_session(conn, Map.put(session, "locale", "en"))
|
||||
%{conn: conn, actor: system_actor}
|
||||
end
|
||||
|
||||
defp log_in_user(conn, user) do
|
||||
conn
|
||||
|> Phoenix.ConnTest.init_test_session(%{})
|
||||
|> AshAuthentication.Plug.Helpers.store_in_session(user)
|
||||
end
|
||||
|
||||
defp open_edit_form(view, custom_field) do
|
||||
view
|
||||
|> element("tr#custom_fields-#{custom_field.id} td", custom_field.name)
|
||||
|> render_click()
|
||||
end
|
||||
|
||||
describe "join_description input" do
|
||||
test "form shows a join_description input", %{conn: conn, actor: actor} do
|
||||
{:ok, custom_field} =
|
||||
CustomField
|
||||
|> Ash.Changeset.for_create(:create, %{name: "dsgvo_field", value_type: :boolean})
|
||||
|> Ash.create(actor: actor)
|
||||
|
||||
{:ok, view, _html} = live(conn, ~p"/admin/datafields")
|
||||
open_edit_form(view, custom_field)
|
||||
|
||||
assert has_element?(view, "input[name='custom_field[join_description]']")
|
||||
end
|
||||
|
||||
test "form shows an info tooltip explaining allowed link syntax", %{conn: conn, actor: actor} do
|
||||
{:ok, custom_field} =
|
||||
CustomField
|
||||
|> Ash.Changeset.for_create(:create, %{name: "dsgvo_field", value_type: :boolean})
|
||||
|> Ash.create(actor: actor)
|
||||
|
||||
{:ok, view, _html} = live(conn, ~p"/admin/datafields")
|
||||
open_edit_form(view, custom_field)
|
||||
|
||||
assert has_element?(
|
||||
view,
|
||||
"[data-testid='join-description-link-hint'] .hero-information-circle"
|
||||
)
|
||||
end
|
||||
|
||||
test "form accepts and persists join_description", %{conn: conn, actor: actor} do
|
||||
{:ok, custom_field} =
|
||||
CustomField
|
||||
|> Ash.Changeset.for_create(:create, %{name: "dsgvo_field", value_type: :boolean})
|
||||
|> Ash.create(actor: actor)
|
||||
|
||||
{:ok, view, _html} = live(conn, ~p"/admin/datafields")
|
||||
open_edit_form(view, custom_field)
|
||||
|
||||
view
|
||||
|> form("#custom-field-form-#{custom_field.id}-form", %{
|
||||
"custom_field" => %{
|
||||
"name" => custom_field.name,
|
||||
"join_description" => "Accept the GDPR at https://example.com/dsgvo"
|
||||
}
|
||||
})
|
||||
|> render_submit()
|
||||
|
||||
updated = Ash.get!(CustomField, custom_field.id, actor: actor)
|
||||
assert updated.join_description == "Accept the GDPR at https://example.com/dsgvo"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -165,6 +165,36 @@ defmodule MvWeb.JoinLiveTest do
|
|||
custom_field.name
|
||||
)
|
||||
end
|
||||
|
||||
@tag role: :unauthenticated
|
||||
test "renders join_description with rendered link as label when set", %{conn: conn} do
|
||||
{:ok, settings} = Membership.get_settings()
|
||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||
|
||||
{:ok, custom_field} =
|
||||
Membership.create_custom_field(
|
||||
%{
|
||||
name: "DSGVO",
|
||||
value_type: :boolean,
|
||||
join_description: "Akzeptiere die [Datenschutzerklärung](https://example.com/dsgvo)"
|
||||
},
|
||||
actor: system_actor
|
||||
)
|
||||
|
||||
{:ok, _} =
|
||||
Membership.update_settings(settings, %{
|
||||
join_form_enabled: true,
|
||||
join_form_field_ids: ["email", custom_field.id],
|
||||
join_form_field_required: %{"email" => true, custom_field.id => false}
|
||||
})
|
||||
|
||||
{:ok, _view, html} = live(conn, "/join")
|
||||
|
||||
assert html =~
|
||||
~s(<a href="https://example.com/dsgvo" class="link link-primary">Datenschutzerklärung</a>)
|
||||
|
||||
assert html =~ "Akzeptiere die"
|
||||
end
|
||||
end
|
||||
|
||||
describe "join field input types" do
|
||||
|
|
|
|||
|
|
@ -409,6 +409,19 @@ defmodule MvWeb.MemberLive.IndexTest do
|
|||
end
|
||||
end
|
||||
|
||||
# Opens the bulk-actions dropdown and clicks the copy item. The copy item only
|
||||
# exists in the DOM while the menu is open, so we toggle it open first.
|
||||
defp click_copy_via_dropdown(view) do
|
||||
view |> element(~s([data-testid="bulk-actions-button"])) |> render_click()
|
||||
view |> element("#bulk-actions-copy") |> render_click()
|
||||
end
|
||||
|
||||
defp scope_badge(html) do
|
||||
html
|
||||
|> LazyHTML.from_fragment()
|
||||
|> LazyHTML.query(~s([data-testid="bulk-actions-scope-badge"]))
|
||||
end
|
||||
|
||||
describe "copy_emails feature" do
|
||||
setup do
|
||||
system_actor = SystemActor.get_system_actor()
|
||||
|
|
@ -460,22 +473,23 @@ defmodule MvWeb.MemberLive.IndexTest do
|
|||
render_click(view, "select_member", %{"id" => member2.id})
|
||||
|
||||
# Trigger copy_emails event
|
||||
view |> element("#copy-emails-btn") |> render_click()
|
||||
click_copy_via_dropdown(view)
|
||||
|
||||
# Verify flash message shows correct count
|
||||
assert render(view) =~ "2"
|
||||
end
|
||||
|
||||
test "copy_emails event with no selection shows error flash", %{conn: conn} do
|
||||
test "copy_emails with no selection copies all members' emails", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, view, _html} = live(conn, "/members")
|
||||
|
||||
# Trigger copy_emails event directly (button not visible when no selection)
|
||||
# This tests the edge case where event is triggered without selection
|
||||
# Deliberate behaviour change (§3.1): with no selection, copy operates on
|
||||
# the current scope (all members) instead of erroring "No members selected".
|
||||
result = render_hook(view, "copy_emails", %{})
|
||||
|
||||
# Should show error flash
|
||||
assert result =~ "No members selected" or result =~ "Keine Mitglieder"
|
||||
# Three seeded members all have an email → success flash, not an error.
|
||||
assert result =~ "3"
|
||||
refute result =~ "No members selected"
|
||||
end
|
||||
|
||||
test "copy_emails event with all members selected formats all emails", %{
|
||||
|
|
@ -488,7 +502,7 @@ defmodule MvWeb.MemberLive.IndexTest do
|
|||
view |> element("[phx-click='select_all']") |> render_click()
|
||||
|
||||
# Trigger copy_emails event
|
||||
view |> element("#copy-emails-btn") |> render_click()
|
||||
click_copy_via_dropdown(view)
|
||||
|
||||
# Verify flash message shows correct count (3 members)
|
||||
assert render(view) =~ "3"
|
||||
|
|
@ -505,7 +519,7 @@ defmodule MvWeb.MemberLive.IndexTest do
|
|||
render_click(view, "select_member", %{"id" => member3.id})
|
||||
|
||||
# Trigger copy_emails event - should not crash
|
||||
view |> element("#copy-emails-btn") |> render_click()
|
||||
click_copy_via_dropdown(view)
|
||||
|
||||
# Verify flash message shows success
|
||||
assert render(view) =~ "1"
|
||||
|
|
@ -582,37 +596,38 @@ defmodule MvWeb.MemberLive.IndexTest do
|
|||
|
||||
# The format should be "Test Format <test.format@example.com>"
|
||||
# We verify this by checking the flash shows 1 email was copied
|
||||
view |> element("#copy-emails-btn") |> render_click()
|
||||
click_copy_via_dropdown(view)
|
||||
assert render(view) =~ "1"
|
||||
end
|
||||
|
||||
test "copy button is disabled when no members selected", %{conn: conn} do
|
||||
test "copy and mailto items stay actionable with no selection", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, view, _html} = live(conn, "/members")
|
||||
|
||||
# Copy button should be disabled (button element)
|
||||
assert has_element?(view, "#copy-emails-btn[disabled]")
|
||||
# Open email button should be disabled (link with tabindex and aria-disabled)
|
||||
assert has_element?(view, "#open-email-btn[tabindex='-1'][aria-disabled='true']")
|
||||
# Open the dropdown so its items are in the DOM.
|
||||
view |> element(~s([data-testid="bulk-actions-button"])) |> render_click()
|
||||
|
||||
# Deliberate behaviour change (§3.1): items are never disabled merely
|
||||
# because nothing is selected. Copy is a plain button (no disabled attr),
|
||||
# and mailto is an enabled link (no aria-disabled) carrying a BCC of all
|
||||
# three seeded members.
|
||||
refute has_element?(view, ~s([data-testid="bulk-actions-copy"][disabled]))
|
||||
refute has_element?(view, ~s([data-testid="bulk-actions-mailto"][aria-disabled="true"]))
|
||||
assert has_element?(view, ~s([data-testid="bulk-actions-mailto"][href^="mailto:"]))
|
||||
end
|
||||
|
||||
test "copy button is enabled after selection", %{
|
||||
test "trigger shows the selected count after a selection", %{
|
||||
conn: conn,
|
||||
member1: member1
|
||||
} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, view, _html} = live(conn, "/members")
|
||||
|
||||
# Select a member by sending the select_member event directly
|
||||
render_click(view, "select_member", %{"id" => member1.id})
|
||||
|
||||
# Copy button should now be enabled (no disabled attribute)
|
||||
refute has_element?(view, "#copy-emails-btn[disabled]")
|
||||
# Open email button should now be enabled (no tabindex=-1 or aria-disabled)
|
||||
refute has_element?(view, "#open-email-btn[tabindex='-1']")
|
||||
refute has_element?(view, "#open-email-btn[aria-disabled='true']")
|
||||
# Counter should show correct count
|
||||
assert render(view) =~ "1"
|
||||
# The scope badge on the trigger reflects the selection count.
|
||||
badge = scope_badge(render(view))
|
||||
assert badge |> LazyHTML.text() |> String.trim() == "1"
|
||||
end
|
||||
|
||||
test "copy button click triggers event and shows flash", %{
|
||||
|
|
@ -626,14 +641,47 @@ defmodule MvWeb.MemberLive.IndexTest do
|
|||
render_click(view, "select_member", %{"id" => member1.id})
|
||||
|
||||
# Click copy button
|
||||
view |> element("#copy-emails-btn") |> render_click()
|
||||
click_copy_via_dropdown(view)
|
||||
|
||||
# Flash message should appear
|
||||
assert has_element?(view, "#flash-group")
|
||||
end
|
||||
|
||||
test "copy excludes a member whose email is blank from the recipient list", %{conn: conn} do
|
||||
# The Member create action requires an email, so a blank-email member cannot
|
||||
# be persisted; we exercise the preserved defensive filter in
|
||||
# format_selected_member_emails/2 directly. One member has an email, the
|
||||
# other has a blank one — only the former is a recipient (§1.10).
|
||||
with_email = %{
|
||||
id: Ecto.UUID.generate(),
|
||||
first_name: "Has",
|
||||
last_name: "Mail",
|
||||
email: "has@example.com"
|
||||
}
|
||||
|
||||
blank_email = %{id: Ecto.UUID.generate(), first_name: "Blank", last_name: "Mail", email: ""}
|
||||
selected = MapSet.new([with_email.id, blank_email.id])
|
||||
|
||||
emails = MemberIndex.format_selected_member_emails([with_email, blank_email], selected)
|
||||
|
||||
assert emails == ["Has Mail <has@example.com>"]
|
||||
_ = conn
|
||||
end
|
||||
end
|
||||
|
||||
describe "export dropdown" do
|
||||
describe "copy_emails empty-recipient feedback" do
|
||||
test "copy with zero recipients shows 'No email addresses found'", %{conn: conn} do
|
||||
# No members exist → the no-selection scope yields zero recipients, so the
|
||||
# preserved empty-recipient feedback fires instead of a clipboard push (§1.11).
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, view, _html} = live(conn, "/members")
|
||||
|
||||
result = render_hook(view, "copy_emails", %{})
|
||||
assert result =~ "No email addresses found" or result =~ "Keine E-Mail"
|
||||
end
|
||||
end
|
||||
|
||||
describe "bulk-actions dropdown" do
|
||||
setup do
|
||||
system_actor = SystemActor.get_system_actor()
|
||||
|
||||
|
|
@ -646,27 +694,72 @@ defmodule MvWeb.MemberLive.IndexTest do
|
|||
%{member1: m1}
|
||||
end
|
||||
|
||||
test "export dropdown button is rendered when no selection and shows (all)", %{conn: conn} do
|
||||
test "trigger is rendered with a muted 'all' scope badge when no selection", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, _view, html} = live(conn, "/members")
|
||||
|
||||
# Dropdown button should be present
|
||||
assert html =~ ~s(data-testid="export-dropdown")
|
||||
assert html =~ ~s(data-testid="export-dropdown-button")
|
||||
assert html =~ "Export"
|
||||
# Button text shows "all" when 0 selected (locale-dependent)
|
||||
assert html =~ "all" or html =~ "All"
|
||||
# The single bulk-actions trigger is present.
|
||||
assert html =~ ~s(data-testid="bulk-actions-dropdown")
|
||||
assert html =~ ~s(data-testid="bulk-actions-button")
|
||||
# The scope is shown as a badge, not a parenthetical text suffix. The test
|
||||
# locale renders the English msgids (German wording lives in de.po):
|
||||
# "Actions" -> "Aktionen", "all" -> "alle".
|
||||
assert html =~ "Actions"
|
||||
refute html =~ "(all)"
|
||||
|
||||
badge = scope_badge(html)
|
||||
assert badge |> LazyHTML.text() |> String.trim() == "all"
|
||||
classes = badge |> LazyHTML.attribute("class") |> List.first()
|
||||
assert classes =~ "badge-neutral"
|
||||
assert classes =~ "badge-sm"
|
||||
end
|
||||
|
||||
test "after select_member event export dropdown shows (1)", %{conn: conn, member1: member1} do
|
||||
test "trigger shows an emphasized count badge after select_member", %{
|
||||
conn: conn,
|
||||
member1: member1
|
||||
} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, view, _html} = live(conn, "/members")
|
||||
|
||||
render_click(view, "select_member", %{"id" => member1.id})
|
||||
|
||||
html = render(view)
|
||||
assert html =~ "Export"
|
||||
assert html =~ "(1)"
|
||||
assert html =~ "Actions"
|
||||
refute html =~ "(1)"
|
||||
|
||||
badge = scope_badge(html)
|
||||
assert badge |> LazyHTML.text() |> String.trim() == "1"
|
||||
classes = badge |> LazyHTML.attribute("class") |> List.first()
|
||||
assert classes =~ "badge-primary"
|
||||
assert classes =~ "badge-sm"
|
||||
end
|
||||
|
||||
test "trigger shows a muted 'filtered' badge when a search narrows the list", %{
|
||||
conn: conn
|
||||
} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, _view, html} = live(conn, "/members?query=Nobody")
|
||||
|
||||
assert html =~ "Actions"
|
||||
refute html =~ "(filtered)"
|
||||
|
||||
badge = scope_badge(html)
|
||||
assert badge |> LazyHTML.text() |> String.trim() == "filtered"
|
||||
classes = badge |> LazyHTML.attribute("class") |> List.first()
|
||||
assert classes =~ "badge-neutral"
|
||||
assert classes =~ "badge-sm"
|
||||
end
|
||||
|
||||
test "trigger carries a trailing chevron", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, view, html} = live(conn, "/members")
|
||||
|
||||
assert html =~ "hero-chevron-down"
|
||||
|
||||
# The bulk-actions trigger and the bespoke member-filter trigger each
|
||||
# carry their own chevron; assert the filter trigger's chevron is pinned
|
||||
# independently, so removing it from the filter component fails this test.
|
||||
assert has_element?(view, ".member-filter-dropdown .hero-chevron-down")
|
||||
end
|
||||
|
||||
test "dropdown opens and closes on click", %{conn: conn} do
|
||||
|
|
@ -674,23 +767,23 @@ defmodule MvWeb.MemberLive.IndexTest do
|
|||
{:ok, view, _html} = live(conn, "/members")
|
||||
|
||||
# Initially closed
|
||||
refute has_element?(view, ~s([data-testid="export-dropdown-menu"]))
|
||||
refute has_element?(view, ~s([data-testid="bulk-actions-menu"]))
|
||||
|
||||
# Click to open
|
||||
view
|
||||
|> element(~s([data-testid="export-dropdown-button"]))
|
||||
|> element(~s([data-testid="bulk-actions-button"]))
|
||||
|> render_click()
|
||||
|
||||
# Menu should be visible
|
||||
assert has_element?(view, ~s([data-testid="export-dropdown-menu"]))
|
||||
assert has_element?(view, ~s([data-testid="bulk-actions-menu"]))
|
||||
|
||||
# Click to close
|
||||
view
|
||||
|> element(~s([data-testid="export-dropdown-button"]))
|
||||
|> element(~s([data-testid="bulk-actions-button"]))
|
||||
|> render_click()
|
||||
|
||||
# Menu should be hidden
|
||||
refute has_element?(view, ~s([data-testid="export-dropdown-menu"]))
|
||||
refute has_element?(view, ~s([data-testid="bulk-actions-menu"]))
|
||||
end
|
||||
|
||||
test "dropdown has click-away and ESC handlers", %{conn: conn} do
|
||||
|
|
@ -699,11 +792,11 @@ defmodule MvWeb.MemberLive.IndexTest do
|
|||
|
||||
# Open dropdown
|
||||
view
|
||||
|> element(~s([data-testid="export-dropdown-button"]))
|
||||
|> element(~s([data-testid="bulk-actions-button"]))
|
||||
|> render_click()
|
||||
|
||||
html = render(view)
|
||||
assert has_element?(view, ~s([data-testid="export-dropdown-menu"]))
|
||||
assert has_element?(view, ~s([data-testid="bulk-actions-menu"]))
|
||||
|
||||
# Check that click-away handler is present
|
||||
assert html =~ ~s(phx-click-away="close_dropdown")
|
||||
|
|
@ -712,13 +805,37 @@ defmodule MvWeb.MemberLive.IndexTest do
|
|||
assert html =~ ~s(phx-key="Escape")
|
||||
end
|
||||
|
||||
test "dropdown menu contains CSV and PDF export links with correct payload", %{conn: conn} do
|
||||
test "menu lists the four actions in order, flattened to one level", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, view, _html} = live(conn, "/members")
|
||||
|
||||
view |> element(~s([data-testid="bulk-actions-button"])) |> render_click()
|
||||
html = render(view)
|
||||
|
||||
# All four items present.
|
||||
assert has_element?(view, ~s([data-testid="bulk-actions-mailto"]))
|
||||
assert has_element?(view, ~s([data-testid="bulk-actions-copy"]))
|
||||
assert has_element?(view, ~s([data-testid="export-csv-link"]))
|
||||
assert has_element?(view, ~s([data-testid="export-pdf-link"]))
|
||||
|
||||
# In order: mailto, copy, CSV, PDF.
|
||||
mailto = :binary.match(html, "bulk-actions-mailto") |> elem(0)
|
||||
copy = :binary.match(html, "bulk-actions-copy") |> elem(0)
|
||||
csv = :binary.match(html, "export-csv-link") |> elem(0)
|
||||
pdf = :binary.match(html, "export-pdf-link") |> elem(0)
|
||||
assert mailto < copy and copy < csv and csv < pdf
|
||||
|
||||
# No nested export submenu — the former standalone export dropdown is gone.
|
||||
refute html =~ ~s(data-testid="export-dropdown")
|
||||
end
|
||||
|
||||
test "menu contains CSV and PDF export forms with identical payload and CSRF", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, view, _html} = live(conn, "/members")
|
||||
|
||||
# Open dropdown
|
||||
view
|
||||
|> element(~s([data-testid="export-dropdown-button"]))
|
||||
|> element(~s([data-testid="bulk-actions-button"]))
|
||||
|> render_click()
|
||||
|
||||
html = render(view)
|
||||
|
|
@ -756,11 +873,11 @@ defmodule MvWeb.MemberLive.IndexTest do
|
|||
# Button should have aria-expanded="false" when closed
|
||||
assert html =~ ~s(aria-expanded="false")
|
||||
# Button should have aria-controls pointing to menu
|
||||
assert html =~ ~s(aria-controls="export-dropdown-menu")
|
||||
assert html =~ ~s(aria-controls="bulk-actions-dropdown-menu")
|
||||
|
||||
# Open dropdown
|
||||
view
|
||||
|> element(~s([data-testid="export-dropdown-button"]))
|
||||
|> element(~s([data-testid="bulk-actions-button"]))
|
||||
|> render_click()
|
||||
|
||||
html = render(view)
|
||||
|
|
@ -782,6 +899,172 @@ defmodule MvWeb.MemberLive.IndexTest do
|
|||
end
|
||||
end
|
||||
|
||||
describe "bulk-actions header layout" do
|
||||
test "header renders one bulk-actions trigger and no standalone copy/mailto/export controls",
|
||||
%{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, view, html} = live(conn, "/members")
|
||||
|
||||
# Exactly one bulk-actions trigger.
|
||||
assert has_element?(view, ~s([data-testid="bulk-actions-button"]))
|
||||
|
||||
# The former standalone controls are gone as top-level header buttons.
|
||||
refute html =~ ~s(id="copy-emails-btn")
|
||||
refute html =~ ~s(id="open-email-btn")
|
||||
refute html =~ ~s(data-testid="export-dropdown-button")
|
||||
end
|
||||
|
||||
test "New Member stays a separate primary button outside the dropdown", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, view, html} = live(conn, "/members")
|
||||
|
||||
# New Member is present as its own control...
|
||||
assert has_element?(view, ~s([data-testid="member-new"]))
|
||||
|
||||
# ...and it is not an item of the bulk-actions menu (it precedes the menu
|
||||
# markup but is not nested inside it).
|
||||
refute html =~ ~r/data-testid="bulk-actions-menu".*data-testid="member-new"/s
|
||||
end
|
||||
end
|
||||
|
||||
describe "mailto recipient cap on the page" do
|
||||
# Seeds n members, each with a distinct email so they all count as mailto
|
||||
# recipients in the no-selection scope.
|
||||
defp seed_members_with_email(n) do
|
||||
system_actor = SystemActor.get_system_actor()
|
||||
|
||||
Enum.each(1..n, fn i ->
|
||||
{:ok, _} =
|
||||
Membership.create_member(
|
||||
%{
|
||||
first_name: "Bulk",
|
||||
last_name: "M#{i}",
|
||||
email: "bulk#{i}@example.com"
|
||||
},
|
||||
actor: system_actor
|
||||
)
|
||||
end)
|
||||
end
|
||||
|
||||
test "mailto item is enabled just below the threshold", %{conn: conn} do
|
||||
seed_members_with_email(Mv.Constants.max_mailto_bulk_recipients() - 1)
|
||||
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, view, _html} = live(conn, "/members")
|
||||
|
||||
view |> element(~s([data-testid="bulk-actions-button"])) |> render_click()
|
||||
|
||||
# 49 recipients → enabled, with an actionable BCC link, no aria-disabled.
|
||||
assert has_element?(view, ~s([data-testid="bulk-actions-mailto"][href^="mailto:"]))
|
||||
refute has_element?(view, ~s([data-testid="bulk-actions-mailto"][aria-disabled="true"]))
|
||||
end
|
||||
|
||||
test "mailto item is disabled with tooltip at the threshold", %{conn: conn} do
|
||||
seed_members_with_email(Mv.Constants.max_mailto_bulk_recipients())
|
||||
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, view, _html} = live(conn, "/members")
|
||||
|
||||
view |> element(~s([data-testid="bulk-actions-button"])) |> render_click()
|
||||
html = render(view)
|
||||
|
||||
# 50 recipients → disabled, with the explanatory tooltip and no BCC link.
|
||||
# The tooltip renders the English msgid in the test locale (German wording
|
||||
# is "Zu viele Empfänger für diese Funktion. ..." in de.po).
|
||||
assert has_element?(view, ~s([data-testid="bulk-actions-mailto"][aria-disabled="true"]))
|
||||
assert html =~ "Too many recipients for this function"
|
||||
refute has_element?(view, ~s([data-testid="bulk-actions-mailto"][href^="mailto:"]))
|
||||
end
|
||||
end
|
||||
|
||||
describe "scope-aware selection assigns" do
|
||||
setup do
|
||||
system_actor = SystemActor.get_system_actor()
|
||||
|
||||
{:ok, m1} =
|
||||
Membership.create_member(
|
||||
%{first_name: "Scope", last_name: "One", email: "scope1@example.com"},
|
||||
actor: system_actor
|
||||
)
|
||||
|
||||
{:ok, m2} =
|
||||
Membership.create_member(
|
||||
%{first_name: "Scope", last_name: "Two", email: "scope2@example.com"},
|
||||
actor: system_actor
|
||||
)
|
||||
|
||||
%{member1: m1, member2: m2}
|
||||
end
|
||||
|
||||
test "scope is :all when nothing selected and no filter", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, view, _html} = live(conn, "/members")
|
||||
|
||||
assigns = :sys.get_state(view.pid).socket.assigns
|
||||
assert assigns.scope == :all
|
||||
end
|
||||
|
||||
test "scope is :filtered when a search term is active", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, view, _html} = live(conn, "/members?query=Scope")
|
||||
|
||||
assigns = :sys.get_state(view.pid).socket.assigns
|
||||
assert assigns.scope == :filtered
|
||||
end
|
||||
|
||||
test "scope is :filtered when a non-search filter is active", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, view, html} = live(conn, "/members?cycle_status_filter=paid")
|
||||
|
||||
assigns = :sys.get_state(view.pid).socket.assigns
|
||||
assert assigns.scope == :filtered
|
||||
|
||||
badge = scope_badge(html)
|
||||
assert badge |> LazyHTML.text() |> String.trim() == "filtered"
|
||||
classes = badge |> LazyHTML.attribute("class") |> List.first()
|
||||
assert classes =~ "badge-neutral"
|
||||
assert classes =~ "badge-sm"
|
||||
end
|
||||
|
||||
test "scope is :selection when a member is selected", %{conn: conn, member1: member1} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, view, _html} = live(conn, "/members")
|
||||
|
||||
render_click(view, "select_member", %{"id" => member1.id})
|
||||
|
||||
assigns = :sys.get_state(view.pid).socket.assigns
|
||||
assert assigns.scope == :selection
|
||||
end
|
||||
|
||||
test "with no selection, recipient_count and mailto_bcc cover all members", %{
|
||||
conn: conn
|
||||
} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, view, _html} = live(conn, "/members")
|
||||
|
||||
assigns = :sys.get_state(view.pid).socket.assigns
|
||||
# Both seeded members have an email, so the no-selection scope covers both.
|
||||
assert assigns.recipient_count == 2
|
||||
assert assigns.mailto_bcc =~ "scope1%40example.com"
|
||||
assert assigns.mailto_bcc =~ "scope2%40example.com"
|
||||
end
|
||||
|
||||
test "with a selection, recipient_count and mailto_bcc cover only the selection", %{
|
||||
conn: conn,
|
||||
member1: member1
|
||||
} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, view, _html} = live(conn, "/members")
|
||||
|
||||
render_click(view, "select_member", %{"id" => member1.id})
|
||||
|
||||
assigns = :sys.get_state(view.pid).socket.assigns
|
||||
assert assigns.recipient_count == 1
|
||||
assert assigns.mailto_bcc =~ "scope1%40example.com"
|
||||
refute assigns.mailto_bcc =~ "scope2%40example.com"
|
||||
end
|
||||
end
|
||||
|
||||
describe "cycle status filter" do
|
||||
# Helper to create a member (only used in this describe block)
|
||||
defp create_member(attrs, actor) do
|
||||
|
|
|
|||
|
|
@ -220,4 +220,59 @@ defmodule MvWeb.MemberLive.ShowTest do
|
|||
assert html =~ "private@example.com"
|
||||
end
|
||||
end
|
||||
|
||||
describe "custom field join_description tooltip" do
|
||||
test "shows a tooltip on the custom field label when join_description is set", %{
|
||||
conn: conn,
|
||||
member: member,
|
||||
actor: actor
|
||||
} do
|
||||
{:ok, custom_field} =
|
||||
CustomField
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
name: "DSGVO",
|
||||
value_type: :boolean,
|
||||
join_description: "Accept the privacy policy"
|
||||
})
|
||||
|> Ash.create(actor: actor)
|
||||
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, view, html} = live(conn, ~p"/members/#{member}")
|
||||
|
||||
assert has_element?(view, "[data-tip*='Accept the privacy policy']")
|
||||
# Tooltip content conveys both the join-form context and the description text.
|
||||
assert has_element?(view, "[data-tip*='Join form:']")
|
||||
assert html =~ "Accept the privacy policy"
|
||||
assert html =~ custom_field.name
|
||||
|
||||
# The info-icon wrapper must center the icon vertically with the label,
|
||||
# matching the flex-items-center idiom used elsewhere (e.g. custom field edit),
|
||||
# so the icon is flush with the label text and not offset downward.
|
||||
assert has_element?(
|
||||
view,
|
||||
"[data-tip*='Accept the privacy policy'].inline-flex.items-center"
|
||||
)
|
||||
end
|
||||
|
||||
test "shows no tooltip on the custom field label when join_description is nil", %{
|
||||
conn: conn,
|
||||
member: member,
|
||||
actor: actor
|
||||
} do
|
||||
{:ok, _custom_field} =
|
||||
CustomField
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
name: "Plain field",
|
||||
value_type: :string
|
||||
})
|
||||
|> Ash.create(actor: actor)
|
||||
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, view, _html} = live(conn, ~p"/members/#{member}")
|
||||
|
||||
assert has_element?(view, "dt", "Plain field")
|
||||
# The info-icon tooltip beside the label is only rendered when join_description is set.
|
||||
refute has_element?(view, "[data-testid='join-description-tooltip']")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -123,5 +123,24 @@ defmodule Mv.SeedsTest do
|
|||
assert mitglied.permission_set_name == "own_data",
|
||||
"Mitglied role must have own_data permission set"
|
||||
end
|
||||
|
||||
test "bootstrap seeds create the DSGVO custom field and not the old long name", %{
|
||||
actor: actor
|
||||
} do
|
||||
{:ok, custom_fields} = Ash.read(Mv.Membership.CustomField, actor: actor)
|
||||
names = Enum.map(custom_fields, & &1.name)
|
||||
|
||||
assert "DSGVO" in names, "Bootstrap seeds must create a custom field named DSGVO"
|
||||
|
||||
refute "Datenschutzerklärung akzeptiert" in names,
|
||||
"Old long field name must no longer be seeded"
|
||||
|
||||
dsgvo = Enum.find(custom_fields, &(&1.name == "DSGVO"))
|
||||
assert dsgvo.value_type == :boolean
|
||||
|
||||
assert dsgvo.join_description ==
|
||||
"Ich habe die [Datenschutzerklärung](https://example.org/datenschutz) gelesen und akzeptiere sie.",
|
||||
"DSGVO field must be seeded with a default join_description"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue