Compare commits

..

No commits in common. "main" and "issue/mila-on-opencode-515" have entirely different histories.

23 changed files with 350 additions and 1058 deletions

1
.gitignore vendored
View file

@ -34,7 +34,6 @@ mv-*.tar
# In case you use Node.js/npm, you want to ignore these.
npm-debug.log
/assets/node_modules/
/node_modules/
.cursor

View file

@ -1,4 +1,3 @@
elixir 1.18.3-otp-27
erlang 27.3.4
just 1.51.0
nodejs 26.2.0
just 1.50.0

View file

@ -17,8 +17,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- **Dynamic CSV import templates** The EN and DE import-template downloads now include the association's current custom fields instead of a fixed column set.
### Changed
- **Member bulk actions in one menu** The actions above the member overview (open in email program, copy email addresses, export to CSV, export to PDF) are now collected in a single "Aktionen" dropdown instead of separate buttons. Without a selection they apply to all members, or to the currently filtered members; the trigger shows the active scope. Opening the email program is disabled when too many recipients are selected, with a hint to copy the addresses or use the export instead.
- **Dropdown buttons** Dropdown buttons (actions, filter, column visibility) now show a chevron so they are recognizable as menus.
- **Default GDPR custom field** The seeded GDPR field was shortened from "Datenschutzerklärung akzeptiert" to "DSGVO" and now ships with a default join-form description (with a placeholder link to replace).
### Fixed

View file

@ -33,7 +33,7 @@ services:
restart: unless-stopped
db-prod:
image: postgres:18.4-alpine
image: postgres:18.3-alpine
container_name: mv-prod-db
environment:
POSTGRES_USER: postgres

View file

@ -4,7 +4,7 @@ networks:
services:
db:
image: postgres:18.4-alpine
image: postgres:18.3-alpine
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
@ -25,7 +25,7 @@ services:
rauthy:
container_name: rauthy-dev
image: ghcr.io/sebadob/rauthy:0.35.2
image: ghcr.io/sebadob/rauthy:0.35.1
environment:
- LOCAL_TEST=true
- SMTP_URL=mailcrab

View file

@ -40,8 +40,6 @@ defmodule Mv.Constants do
@max_boolean_filters 50
@max_mailto_bulk_recipients 50
@max_uuid_length 36
@email_validator_checks [:html_input, :pow]
@ -175,21 +173,6 @@ defmodule Mv.Constants do
"""
def max_boolean_filters, do: @max_boolean_filters
@doc """
Returns the maximum number of mailto recipients before the bulk "open in email
program" action is disabled.
The mailto link carries every recipient in its BCC; browsers cannot reliably
hand a too-long mailto URI to the mail program. At or above this count the
action is disabled in the UI (Copy and Export have no such limit).
## Examples
iex> Mv.Constants.max_mailto_bulk_recipients()
50
"""
def max_mailto_bulk_recipients, do: @max_mailto_bulk_recipients
@doc """
Returns the maximum length of a UUID string (36 characters including hyphens).

View file

@ -1,243 +0,0 @@
defmodule MvWeb.Components.BulkActionsDropdown do
@moduledoc """
Single "Aktionen" dropdown bundling the four member bulk actions, flattened to
one level: open in email program (mailto), copy email addresses, export to CSV,
export to PDF.
It keeps the CSRF-protected `<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-70"
menu_align="left"
button_class="btn-secondary gap-2"
testid="bulk-actions-dropdown"
button_testid="bulk-actions-button"
menu_testid="bulk-actions-menu"
>
<:trigger_badge>
<.badge variant={@scope_variant} size="sm" data-testid="bulk-actions-scope-badge">
{@scope_label}
</.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

View file

@ -464,9 +464,6 @@ defmodule MvWeb.CoreComponents do
slot :inner_block, doc: "Custom content for the dropdown menu (e.g., forms)"
slot :trigger_badge,
doc: "Optional badge rendered in the trigger after the label (e.g. a scope badge)"
def dropdown_menu(assigns) do
menu_testid = assigns.menu_testid || "#{assigns.testid}-menu"
@ -501,8 +498,6 @@ defmodule MvWeb.CoreComponents do
<.icon name={@icon} />
<% end %>
<span>{@button_label}</span>
{render_slot(@trigger_badge)}
<.icon name="hero-chevron-down" class="size-4" />
</button>
<ul

View file

@ -0,0 +1,110 @@
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

View file

@ -156,7 +156,6 @@ defmodule MvWeb.Components.MemberFilterComponent do
>
{@member_count}
</.badge>
<.icon name="hero-chevron-down" class="size-4" />
</.button>
<!--

View file

@ -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 the selected members, or of all/filtered members when nothing is selected
- `copy_emails` - Copy email addresses of selected members to clipboard
## Implementation Notes
- Search uses PostgreSQL full-text search (plainto_tsquery)
@ -250,42 +250,41 @@ 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))
# 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?)
# Filter members that are in the selection and have email addresses
formatted_emails = format_selected_member_emails(socket.assigns.members, selected_ids)
email_count = length(formatted_emails)
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, ", ")
cond do
MapSet.size(selected_ids) == 0 ->
{:noreply, put_flash(socket, :error, gettext("No members selected"))}
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
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")
)
)
|> put_flash(
:warning,
gettext("Tip: Paste email addresses into the BCC field for privacy compliance")
)
{:noreply, socket}
{:noreply, socket}
end
end
@ -1813,79 +1812,24 @@ 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 =
recipient_emails
|> Enum.join(", ")
|> URI.encode_www_form()
|> String.replace("+", "%20")
mailto_disabled? = recipient_count >= Mv.Constants.max_mailto_bulk_recipients()
if any_selected? do
format_selected_member_emails(members, selected_members)
|> Enum.join(", ")
|> URI.encode_www_form()
|> String.replace("+", "%20")
else
""
end
socket
|> assign(:selected_count, selected_count)
|> assign(:scope, scope)
|> assign(:recipient_count, recipient_count)
|> assign(:mailto_disabled?, mailto_disabled?)
|> assign(:any_selected?, any_selected?)
|> 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))

View file

@ -3,15 +3,32 @@
{@content_title}
<:actions>
<.live_component
module={MvWeb.Components.BulkActionsDropdown}
id="bulk-actions-dropdown"
module={MvWeb.Components.ExportDropdown}
id="export-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")}

View file

@ -39,15 +39,15 @@ defmodule Mv.MixProject do
[
{:tidewave, "~> 0.5", only: [:dev]},
{:sourceror, "~> 1.8", only: [:dev, :test]},
{:live_debugger, "~> 1.0", only: [:dev]},
{:ash_admin, "~> 1.0"},
{:live_debugger, "~> 0.8", only: [:dev]},
{:ash_admin, "~> 0.14"},
{:ash_postgres, "~> 2.0"},
{:ash_phoenix, "~> 2.0"},
{:ash, "~> 3.0"},
{:bcrypt_elixir, "~> 3.0"},
{:ash_authentication, "~> 4.9"},
{:ash_authentication_phoenix, "~> 2.10"},
{:igniter, "~> 0.8", only: [:dev, :test]},
{:igniter, "~> 0.7", only: [:dev, :test]},
{:phoenix, "~> 1.8.0-rc.4", override: true},
{:phoenix_ecto, "~> 4.5"},
{:ecto_sql, "~> 3.10"},

View file

@ -1,6 +1,6 @@
%{
"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": {: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_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.19", "6903cabdfd9d1af46454126e7c8385186659dd33ecfb74a885cae52221ad6109", [:mix], [], "hexpm", "3669e6cab13f54c2df26b3e6833745d647f35b6e30d8ddd5975df0d5c842ca98"},
"castore": {:hex, :castore, "1.0.18", "5e43ef0ec7d31195dfa5a65a86e6131db999d074179d2ba5a8de11fe14570f55", [:mix], [], "hexpm", "f393e4fe6317829b158fb74d86eb681f737d2fe326aa61ccf6293c4104957e34"},
"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.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"},
"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"},
"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.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"},
"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"},
"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.1", "430d87b04011ce6cbd4fd205be758311a81f87d552d40904abd00f015935b1d0", [:mix], [], "hexpm", "c5f25f2ced74a0587d03e6023f595db8e924c9d3922c8c8ffd9edfc4498cf1f6"},
"decimal": {:hex, :decimal, "3.1.0", "9ede268cff827e6f0c4fb1b34747c82630dce5d7b877dfb22ec8f0cb25855fce", [:mix], [], "hexpm", "e8b3efb3bb3a13cb5e4268ffe128569067b1972e9dee013537c71a5b073168f9"},
"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,11 +32,10 @@
"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.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"},
"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"},
"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"},
@ -45,7 +44,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.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"},
"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"},
"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"},
@ -53,23 +52,22 @@
"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, "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"},
"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"},
"luhn": {:hex, :luhn, "0.3.3", "5aa0c6a32c2db4b9db9f9b883ba8301c1ae169d57199b9e6cb1ba2707bc51d96", [:mix], [], "hexpm", "3e823a913a25aab51352c727f135278d22954874d5f0835be81ed4fec3daf78d"},
"mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"},
"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"},
"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"},
"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.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": {: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_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.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_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_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"},
@ -80,15 +78,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.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"},
"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"},
"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.12", "0f7780e4c6ea3753b65ea0c4924f3dfd5c21a51aaa734ffb9dd0b68d2544f27e", [:mix], [], "hexpm", "a389931287b85330c0e954ab06447e198516ab368a232a0200ed77ca13ca9acf"},
"spitfire": {:hex, :spitfire, "0.3.11", "79dfcb033762470de472c1c26ea2b4e3aca74700c685dbffd9a13466272c323d", [:mix], [], "hexpm", "eb6e2dadf63214e8bfe65ca9788cef2b03b01027365d78d3c0e3d9ebd3d5b7b4"},
"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"},
@ -98,13 +96,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.5.0", "f50a213cac97262b6d5ebb85745aa2c00fec1413191e6e66834788d45425cecb", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "708923d40523e43cf99041ab37a0d4b0ec426ac6438fa3716ab23d919eaeb412"},
"thousand_island": {:hex, :thousand_island, "1.4.3", "2158209580f633be38d43ec4e3ce0a01079592b9657afff9080d5d8ca149a3af", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "6e4ce09b0fd761a58594d02814d40f77daff460c48a7354a15ab353bb998ea0b"},
"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.2", "9dd1330fb4cd9a36a7b0f502e5b12486eff632792ee4a5f0eba52a4d4ec32c9c", [:mix], [{:yamerl, "~> 0.10", [hex: :yamerl, repo: "hexpm", optional: false]}], "hexpm", "e7c1b10122f973e6558462d51c39026ba0e14afbc6745318e990ea82cfe9e159"},
"yaml_elixir": {:hex, :yaml_elixir, "2.12.1", "d74f2d82294651b58dac849c45a82aaea639766797359baff834b64439f6b3f4", [:mix], [{:yamerl, "~> 0.10", [hex: :yamerl, repo: "hexpm", optional: false]}], "hexpm", "d9ac16563c737d55f9bfeed7627489156b91268a3a21cd55c54eb2e335207fed"},
"ymlr": {:hex, :ymlr, "5.1.5", "0b9207c7940be3f2bc29b77cd55109d5aa2f4dcde6575942017335769e6f5628", [:mix], [], "hexpm", "7030cb240c46850caeb3b01be745307632be319b15f03083136f6251f49b516d"},
}

View file

@ -84,7 +84,6 @@ 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
@ -367,6 +366,11 @@ 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"
@ -666,11 +670,16 @@ 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/components/bulk_actions_dropdown.ex
#: lib/mv_web/live/member_live/index.html.heex
#, 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."
@ -1189,17 +1198,22 @@ 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/bulk_actions_dropdown.ex
#: lib/mv_web/components/export_dropdown.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Export members to CSV"
msgstr "Mitglieder als CSV exportieren"
#: lib/mv_web/components/bulk_actions_dropdown.ex
#: lib/mv_web/components/export_dropdown.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Export members to PDF"
msgstr "Mitglieder als PDF exportieren"
@ -2192,6 +2206,11 @@ 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."
@ -2345,7 +2364,12 @@ 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/components/bulk_actions_dropdown.ex
#: 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
#, elixir-autogen, elixir-format
msgid "Open in email program"
msgstr "Im E-Mail-Programm öffnen"
@ -2371,6 +2395,11 @@ 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
@ -3545,7 +3574,7 @@ msgstr "Dein Passwort wurde erfolgreich zurückgesetzt"
msgid "admin - Unrestricted access"
msgstr "admin Uneingeschränkter Zugriff"
#: lib/mv_web/components/bulk_actions_dropdown.ex
#: lib/mv_web/components/export_dropdown.ex
#, elixir-autogen, elixir-format
msgid "all"
msgstr "alle"
@ -4062,53 +4091,3 @@ 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"

View file

@ -85,7 +85,6 @@ 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
@ -368,6 +367,11 @@ 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"
@ -667,11 +671,16 @@ msgid_plural "Copied %{count} email addresses to clipboard"
msgstr[0] ""
msgstr[1] ""
#: lib/mv_web/components/bulk_actions_dropdown.ex
#: lib/mv_web/live/member_live/index.html.heex
#, 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."
@ -1190,17 +1199,22 @@ 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/bulk_actions_dropdown.ex
#: lib/mv_web/components/export_dropdown.ex
#, elixir-autogen, elixir-format
msgid "Export members to CSV"
msgstr ""
#: lib/mv_web/components/bulk_actions_dropdown.ex
#: lib/mv_web/components/export_dropdown.ex
#, elixir-autogen, elixir-format
msgid "Export members to PDF"
msgstr ""
@ -2193,6 +2207,11 @@ 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."
@ -2346,7 +2365,12 @@ msgstr ""
msgid "Only possible if no members are assigned to this type."
msgstr ""
#: lib/mv_web/components/bulk_actions_dropdown.ex
#: 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
#, elixir-autogen, elixir-format
msgid "Open in email program"
msgstr ""
@ -2372,6 +2396,11 @@ 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
@ -3545,7 +3574,7 @@ msgstr ""
msgid "admin - Unrestricted access"
msgstr ""
#: lib/mv_web/components/bulk_actions_dropdown.ex
#: lib/mv_web/components/export_dropdown.ex
#, elixir-autogen, elixir-format
msgid "all"
msgstr ""
@ -4062,23 +4091,3 @@ 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 ""

View file

@ -85,7 +85,6 @@ 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
@ -368,6 +367,11 @@ 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"
@ -667,11 +671,16 @@ msgid_plural "Copied %{count} email addresses to clipboard"
msgstr[0] ""
msgstr[1] ""
#: lib/mv_web/components/bulk_actions_dropdown.ex
#: lib/mv_web/live/member_live/index.html.heex
#, 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."
@ -1190,17 +1199,22 @@ 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/bulk_actions_dropdown.ex
#: lib/mv_web/components/export_dropdown.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Export members to CSV"
msgstr ""
#: lib/mv_web/components/bulk_actions_dropdown.ex
#: lib/mv_web/components/export_dropdown.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Export members to PDF"
msgstr ""
@ -2193,6 +2207,11 @@ 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."
@ -2346,7 +2365,12 @@ msgstr ""
msgid "Only possible if no members are assigned to this type."
msgstr ""
#: lib/mv_web/components/bulk_actions_dropdown.ex
#: 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
#, elixir-autogen, elixir-format
msgid "Open in email program"
msgstr ""
@ -2372,6 +2396,11 @@ 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
@ -3545,7 +3574,7 @@ msgstr ""
msgid "admin - Unrestricted access"
msgstr ""
#: lib/mv_web/components/bulk_actions_dropdown.ex
#: lib/mv_web/components/export_dropdown.ex
#, elixir-autogen, elixir-format
msgid "all"
msgstr ""
@ -4062,53 +4091,3 @@ 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 ""

View file

@ -1,7 +1,6 @@
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

View file

@ -1,155 +0,0 @@
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

View file

@ -17,13 +17,5 @@ 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

View file

@ -49,20 +49,6 @@ 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"})

View file

@ -82,10 +82,8 @@ 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 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)
# Count occurrences to ensure only one descending icon
down_count = html |> String.split("hero-chevron-down ") |> length() |> Kernel.-(1)
# Should be exactly one chevrondown icon
assert down_count == 1
end
@ -160,7 +158,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 = active_sort_down_count(html_neutral)
down_count = html_neutral |> String.split("hero-chevron-down ") |> length() |> Kernel.-(1)
assert up_count == 1, "Expected exactly 1 ascending icon, got #{up_count}"
assert down_count == 0, "Expected 0 descending icons, got #{down_count}"
@ -169,24 +167,13 @@ 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 = active_sort_down_count(html_desc)
down_count = html_desc |> String.split("hero-chevron-down ") |> length() |> Kernel.-(1)
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)

View file

@ -409,19 +409,6 @@ 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()
@ -473,23 +460,22 @@ defmodule MvWeb.MemberLive.IndexTest do
render_click(view, "select_member", %{"id" => member2.id})
# Trigger copy_emails event
click_copy_via_dropdown(view)
view |> element("#copy-emails-btn") |> render_click()
# Verify flash message shows correct count
assert render(view) =~ "2"
end
test "copy_emails with no selection copies all members' emails", %{conn: conn} do
test "copy_emails event with no selection shows error flash", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Deliberate behaviour change (§3.1): with no selection, copy operates on
# the current scope (all members) instead of erroring "No members selected".
# Trigger copy_emails event directly (button not visible when no selection)
# This tests the edge case where event is triggered without selection
result = render_hook(view, "copy_emails", %{})
# Three seeded members all have an email → success flash, not an error.
assert result =~ "3"
refute result =~ "No members selected"
# Should show error flash
assert result =~ "No members selected" or result =~ "Keine Mitglieder"
end
test "copy_emails event with all members selected formats all emails", %{
@ -502,7 +488,7 @@ defmodule MvWeb.MemberLive.IndexTest do
view |> element("[phx-click='select_all']") |> render_click()
# Trigger copy_emails event
click_copy_via_dropdown(view)
view |> element("#copy-emails-btn") |> render_click()
# Verify flash message shows correct count (3 members)
assert render(view) =~ "3"
@ -519,7 +505,7 @@ defmodule MvWeb.MemberLive.IndexTest do
render_click(view, "select_member", %{"id" => member3.id})
# Trigger copy_emails event - should not crash
click_copy_via_dropdown(view)
view |> element("#copy-emails-btn") |> render_click()
# Verify flash message shows success
assert render(view) =~ "1"
@ -596,38 +582,37 @@ 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
click_copy_via_dropdown(view)
view |> element("#copy-emails-btn") |> render_click()
assert render(view) =~ "1"
end
test "copy and mailto items stay actionable with no selection", %{conn: conn} do
test "copy button is disabled when no members selected", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# 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:"]))
# 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']")
end
test "trigger shows the selected count after a selection", %{
test "copy button is enabled after 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})
# The scope badge on the trigger reflects the selection count.
badge = scope_badge(render(view))
assert badge |> LazyHTML.text() |> String.trim() == "1"
# 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"
end
test "copy button click triggers event and shows flash", %{
@ -641,47 +626,14 @@ defmodule MvWeb.MemberLive.IndexTest do
render_click(view, "select_member", %{"id" => member1.id})
# Click copy button
click_copy_via_dropdown(view)
view |> element("#copy-emails-btn") |> render_click()
# 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 "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
describe "export dropdown" do
setup do
system_actor = SystemActor.get_system_actor()
@ -694,72 +646,27 @@ defmodule MvWeb.MemberLive.IndexTest do
%{member1: m1}
end
test "trigger is rendered with a muted 'all' scope badge when no selection", %{conn: conn} do
test "export dropdown button is rendered when no selection and shows (all)", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members")
# 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"
# 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"
end
test "trigger shows an emphasized count badge after select_member", %{
conn: conn,
member1: member1
} do
test "after select_member event export dropdown shows (1)", %{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 =~ "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")
assert html =~ "Export"
assert html =~ "(1)"
end
test "dropdown opens and closes on click", %{conn: conn} do
@ -767,23 +674,23 @@ defmodule MvWeb.MemberLive.IndexTest do
{:ok, view, _html} = live(conn, "/members")
# Initially closed
refute has_element?(view, ~s([data-testid="bulk-actions-menu"]))
refute has_element?(view, ~s([data-testid="export-dropdown-menu"]))
# Click to open
view
|> element(~s([data-testid="bulk-actions-button"]))
|> element(~s([data-testid="export-dropdown-button"]))
|> render_click()
# Menu should be visible
assert has_element?(view, ~s([data-testid="bulk-actions-menu"]))
assert has_element?(view, ~s([data-testid="export-dropdown-menu"]))
# Click to close
view
|> element(~s([data-testid="bulk-actions-button"]))
|> element(~s([data-testid="export-dropdown-button"]))
|> render_click()
# Menu should be hidden
refute has_element?(view, ~s([data-testid="bulk-actions-menu"]))
refute has_element?(view, ~s([data-testid="export-dropdown-menu"]))
end
test "dropdown has click-away and ESC handlers", %{conn: conn} do
@ -792,11 +699,11 @@ defmodule MvWeb.MemberLive.IndexTest do
# Open dropdown
view
|> element(~s([data-testid="bulk-actions-button"]))
|> element(~s([data-testid="export-dropdown-button"]))
|> render_click()
html = render(view)
assert has_element?(view, ~s([data-testid="bulk-actions-menu"]))
assert has_element?(view, ~s([data-testid="export-dropdown-menu"]))
# Check that click-away handler is present
assert html =~ ~s(phx-click-away="close_dropdown")
@ -805,37 +712,13 @@ defmodule MvWeb.MemberLive.IndexTest do
assert html =~ ~s(phx-key="Escape")
end
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
test "dropdown menu contains CSV and PDF export links with correct payload", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Open dropdown
view
|> element(~s([data-testid="bulk-actions-button"]))
|> element(~s([data-testid="export-dropdown-button"]))
|> render_click()
html = render(view)
@ -873,11 +756,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="bulk-actions-dropdown-menu")
assert html =~ ~s(aria-controls="export-dropdown-menu")
# Open dropdown
view
|> element(~s([data-testid="bulk-actions-button"]))
|> element(~s([data-testid="export-dropdown-button"]))
|> render_click()
html = render(view)
@ -899,172 +782,6 @@ 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