Compare commits

..

17 commits

Author SHA1 Message Date
17ef898274
Gettext: translate Vereinfacht API validation messages to German
Some checks failed
continuous-integration/drone/push Build is failing
2026-02-19 00:54:45 +01:00
75567a1c0a
Clear Vereinfacht ENV in test_helper so tests never hit real API 2026-02-19 00:54:01 +01:00
361e33adaf
Vereinfacht: update existing contact when found by email
Before saving contact_id to member, sync current data to the
existing contact so Vereinfacht stays up to date.
2026-02-19 00:16:04 +01:00
62000562f0
Vereinfacht client: find by email in response, no retries in test
API does not allow filter[email]; fetch list and match client-side.
Disable Req retries in test for fast failure and less log noise.
2026-02-19 00:15:58 +01:00
4cdd187b43
Gettext: new Vereinfacht UI strings and German translations
(set), Leave blank to keep current, env hint; DE msgstr added.
2026-02-19 00:15:52 +01:00
329c2d50ec
Global settings: API key redaction and per-field ENV
Never put API key in form/DOM; show (set) badge, drop blank on save.
Per-field disabled when ENV set; save button only when not all from ENV.
2026-02-19 00:15:46 +01:00
e864dee8fe
Config: per-field Vereinfacht ENV helpers
vereinfacht_api_url_env_set?, vereinfacht_api_key_env_set?,
vereinfacht_club_id_env_set? for read-only Settings fields when set.
2026-02-19 00:15:37 +01:00
fb7d7589bb
Add Vereinfacht ENV vars to .env.example
VEREINFACHT_API_URL, VEREINFACHT_API_KEY, VEREINFACHT_CLUB_ID
with short comment that they override Settings when set.
2026-02-19 00:15:28 +01:00
9db5b7f292
Vereinfacht: sync linked member only when email or member changed
Run SyncLinkedMemberAfterUserChange only when email or member
relationship changed to avoid unnecessary API calls.
2026-02-19 00:15:17 +01:00
81f62a7c85
fix(a11y): WCAG 2 AA contrast and keyboard access
All checks were successful
continuous-integration/drone/push Build is passing
2026-02-18 22:52:29 +01:00
f168d3f093
test(vereinfacht): add tests and scope README
Some checks failed
continuous-integration/drone/push Build is failing
- Config, Client, SyncContact, Vereinfacht module tests (no real API)
- vereinfacht_test_README: document test scope
2026-02-18 22:31:30 +01:00
5628de7bc6
feat(vereinfacht): gettext and German translations
- POT/PO: Vereinfacht UI and API error message strings
2026-02-18 22:31:24 +01:00
d0fa3991f7
feat(vereinfacht): member form flash and show page
- Form: show Vereinfacht sync warning after save via SyncFlash
- Show: load API debug response; MembershipFees: contact ID, link, no-contact warning
2026-02-18 22:31:19 +01:00
81bcd2bc4d
feat(vereinfacht): Settings UI and bulk sync
- GlobalSettingsLive: Vereinfacht section, sync button, last sync result
- Test: Vereinfacht Integration section visible
2026-02-18 22:30:29 +01:00
9808dba007
feat(vereinfacht): sync linked member after user email/link changes
- SyncLinkedMemberAfterUserChange on update, create_user, update_user,
  admin_set_password, link_oidc_id, register_with_rauthy
2026-02-18 22:30:21 +01:00
3a61699dd2
feat(vereinfacht): add client, sync flash and SyncContact change
- Application: create SyncFlash ETS table on start
- Vereinfacht: Client, SyncFlash, sync_member, format_error, sync_members_without_contact
- SyncContact change on Member create_member and update_member
- Member: attribute vereinfacht_contact_id, internal action set_vereinfacht_contact_id
2026-02-18 22:30:15 +01:00
b775f5f5c4
feat(vereinfacht): add DB schema, config and setting attributes
- Migrations: vereinfacht_contact_id on members, vereinfacht_* on settings
- Mv.Config: Vereinfacht ENV/Settings helpers, vereinfacht_configured?, contact_view_url
- Setting: vereinfacht_api_url, api_key, club_id
2026-02-18 22:30:03 +01:00
54 changed files with 748 additions and 2214 deletions

View file

@ -84,7 +84,7 @@ steps:
# Fetch dependencies # Fetch dependencies
- mix deps.get - mix deps.get
# Run fast tests (excludes slow/performance and UI tests) # Run fast tests (excludes slow/performance and UI tests)
- mix test --exclude slow --exclude ui --max-cases 2 - mix test --exclude slow --exclude ui
- name: rebuild-cache - name: rebuild-cache
image: drillster/drone-volume-cache image: drillster/drone-volume-cache

View file

@ -36,4 +36,3 @@ ASSOCIATION_NAME="Sportsclub XYZ"
# VEREINFACHT_API_URL=https://api.verein.visuel.dev/api/v1 # VEREINFACHT_API_URL=https://api.verein.visuel.dev/api/v1
# VEREINFACHT_API_KEY=your-api-key # VEREINFACHT_API_KEY=your-api-key
# VEREINFACHT_CLUB_ID=2 # VEREINFACHT_CLUB_ID=2
# VEREINFACHT_APP_URL=https://app.verein.visuel.dev

View file

@ -385,8 +385,6 @@ def process_user(user), do: {:ok, perform_action(user)}
### 2.3 Error Handling ### 2.3 Error Handling
**No silent failures:** When an error path assigns a fallback (e.g. empty list, unchanged assigns), always log the error with enough context (e.g. `inspect(error)`, slug, action) and/or set a user-visible flash. Do not only assign the fallback without logging.
**Use Tagged Tuples:** **Use Tagged Tuples:**
```elixir ```elixir
@ -625,10 +623,6 @@ defmodule MvWeb.MemberLive.Index do
end end
``` ```
**LiveView load budget:** Keep UI-triggered events cheap. `phx-change`, `phx-focus`, and `phx-keydown` must **not** perform database reads by default; work from assigns (e.g. filter in memory) or defer reads to an explicit commit step (e.g. "Add", "Save", "Submit"). Perform DB reads or reloads only on commit events, not on every keystroke or focus. If a read in an event is unavoidable, do at most one deliberate read, document why, and prefer debounce/throttle.
**LiveView size:** When a LiveView accumulates many features and event handlers (CRUD + add/remove + search + keyboard + modals), extract sub-flows into LiveComponents. The parent keeps auth, initial load, and a single reload after child actions; the component owns the sub-flow and notifies the parent when data changes.
**Component Design:** **Component Design:**
```elixir ```elixir
@ -1264,8 +1258,6 @@ end
### 3.12 Internationalization: Gettext ### 3.12 Internationalization: Gettext
**German (de):** Use informal address (“duzen”). All user-facing German text should address the user as “du” (e.g. “Bitte versuche es erneut”, “Deine Einstellungen”), not “Sie”.
**Define Translations:** **Define Translations:**
```elixir ```elixir
@ -1275,9 +1267,6 @@ gettext("Welcome to Mila")
# With interpolation # With interpolation
gettext("Hello, %{name}!", name: user.name) gettext("Hello, %{name}!", name: user.name)
# Plural: always pass count binding when message uses %{count}
ngettext("Found %{count} member", "Found %{count} members", @count, count: @count)
# Domain-specific translations # Domain-specific translations
dgettext("auth", "Sign in with email") dgettext("auth", "Sign in with email")
``` ```
@ -1518,8 +1507,6 @@ defmodule MvWeb.MemberLive.IndexTest do
end end
``` ```
**LiveView test standards:** Prefer selector-based assertions (`has_element?` on `data-testid`, stable IDs, or semantic markers) over free-text matching (`html =~ "..."`) or broad regex. For repeated flows (e.g. "open add member", "search", "select"), use helpers in `test/support/`. If delete is a LiveView event, test that event and assert both UI and data; if delete uses JS `data-confirm` + non-LV submit, cover the real delete path in a context/service test and add at most one smoke test in the UI. Do not assert or document "single request" or "DB-level sort" without measuring (e.g. query count or timing).
#### 4.3.5 Component Tests #### 4.3.5 Component Tests
Test function components: Test function components:
@ -1889,8 +1876,6 @@ policies do
end end
``` ```
**Record-based authorization:** When a concrete record is available, use `can?(actor, :action, record)` (e.g. `can?(@current_user, :update, @group)`). Use `can?(actor, :action, Resource)` only when no specific record exists (e.g. "can create any group"). In events: resolve the record from assigns, run `can?`, then mutate; avoid an extra DB read just for a "freshness" check if the assign is already the source of truth.
**Actor Handling in LiveViews:** **Actor Handling in LiveViews:**
Always use the `current_actor/1` helper for consistent actor access: Always use the `current_actor/1` helper for consistent actor access:
@ -2722,9 +2707,7 @@ Building accessible applications ensures that all users, including those with di
### 8.2 ARIA Labels and Roles ### 8.2 ARIA Labels and Roles
**Terminology and semantics:** Use the same terms in UI, tests, and docs (e.g. "modal" vs "inline confirmation"). If the implementation is inline, do not call it a modal in tests or docs. **Use ARIA Attributes When Necessary:**
**Use ARIA Attributes When Necessary:** Use `role="status"` only for live regions that present advisory status (e.g. search result count, progress). Do not use it on links, buttons, or purely static labels; use semantic HTML or the correct role for the widget. Attach `phx-debounce` / `phx-throttle` to the element that triggers the event (e.g. input), not to the whole form, unless the intent is form-wide.
```heex ```heex
<!-- Icon-only buttons need labels --> <!-- Icon-only buttons need labels -->
@ -2948,11 +2931,11 @@ end
**Announce Dynamic Content:** **Announce Dynamic Content:**
```heex ```heex
<!-- Search results announcement (count: required so %{count} is replaced and pluralisation works) --> <!-- Search results announcement -->
<div role="status" aria-live="polite" aria-atomic="true"> <div role="status" aria-live="polite" aria-atomic="true">
<%= if @searched do %> <%= if @searched do %>
<span class="sr-only"> <span class="sr-only">
<%= ngettext("Found %{count} member", "Found %{count} members", @count, count: @count) %> <%= ngettext("Found %{count} member", "Found %{count} members", @count) %>
</span> </span>
<% end %> <% end %>
</div> </div>

View file

@ -7,25 +7,25 @@
# This file is based on these images: # This file is based on these images:
# #
# - https://hub.docker.com/r/hexpm/elixir/tags - for the build image # - https://hub.docker.com/r/hexpm/elixir/tags - for the build image
# - https://hub.docker.com/_/debian?tab=tags&page=1&name=trixie-20260202-slim - for the release image # - https://hub.docker.com/_/debian?tab=tags&page=1&name=bullseye-20250317-slim - for the release image
# - https://pkgs.org/ - resource for finding needed packages # - https://pkgs.org/ - resource for finding needed packages
# - Ex: hexpm/elixir:1.18.3-erlang-27.3-debian-trixie-20260202-slim # - Ex: hexpm/elixir:1.18.3-erlang-27.3-debian-bullseye-20250317-slim
# #
ARG BUILDER_IMAGE="hexpm/elixir:1.18.3-erlang-27.3-debian-trixie-20260202-slim" ARG BUILDER_IMAGE="hexpm/elixir:1.18.3-erlang-27.3-debian-bullseye-20250317-slim"
ARG RUNNER_IMAGE="debian:trixie-20260202-slim" ARG RUNNER_IMAGE="debian:bullseye-20250317-slim"
FROM ${BUILDER_IMAGE} AS builder FROM ${BUILDER_IMAGE} AS builder
# install build dependencies # install build dependencies
RUN apt-get update -y && apt-get install -y build-essential git \ RUN apt-get update -y && apt-get install -y build-essential git \
&& apt-get clean && rm -f /var/lib/apt/lists/*_* && apt-get clean && rm -f /var/lib/apt/lists/*_*
# prepare build dir # prepare build dir
WORKDIR /app WORKDIR /app
# install hex + rebar # install hex + rebar
RUN mix local.hex --force && \ RUN mix local.hex --force && \
mix local.rebar --force mix local.rebar --force
# set build ENV # set build ENV
ENV MIX_ENV="prod" ENV MIX_ENV="prod"
@ -64,7 +64,7 @@ RUN mix release
FROM ${RUNNER_IMAGE} FROM ${RUNNER_IMAGE}
RUN apt-get update -y && \ RUN apt-get update -y && \
apt-get install -y libstdc++6 openssl libncurses6 locales ca-certificates \ apt-get install -y libstdc++6 openssl libncurses5 locales ca-certificates \
&& apt-get clean && rm -f /var/lib/apt/lists/*_* && apt-get clean && rm -f /var/lib/apt/lists/*_*
# Set the locale # Set the locale

View file

@ -24,7 +24,7 @@
@plugin "../vendor/daisyui-theme" { @plugin "../vendor/daisyui-theme" {
name: "dark"; name: "dark";
default: false; default: false;
prefersdark: false; prefersdark: true;
color-scheme: "dark"; color-scheme: "dark";
--color-base-100: oklch(30.33% 0.016 252.42); --color-base-100: oklch(30.33% 0.016 252.42);
--color-base-200: oklch(25.26% 0.014 253.1); --color-base-200: oklch(25.26% 0.014 253.1);

View file

@ -10,7 +10,7 @@ defmodule Mv.Membership.CustomField do
## Attributes ## Attributes
- `name` - Unique identifier for the custom field (e.g., "phone_mobile", "birthday") - `name` - Unique identifier for the custom field (e.g., "phone_mobile", "birthday")
- `slug` - URL-friendly, immutable identifier automatically generated from name (e.g., "phone-mobile") - `slug` - URL-friendly, immutable identifier automatically generated from name (e.g., "phone-mobile")
- `value_type` - Data type constraint (`:string`, `:integer`, `:boolean`, `:date`, `:email`). Immutable after creation. - `value_type` - Data type constraint (`:string`, `:integer`, `:boolean`, `:date`, `:email`)
- `description` - Optional human-readable description - `description` - Optional human-readable description
- `required` - If true, all members must have this custom field (future feature) - `required` - If true, all members must have this custom field (future feature)
- `show_in_overview` - If true, this custom field will be displayed in the member overview table and can be sorted - `show_in_overview` - If true, this custom field will be displayed in the member overview table and can be sorted
@ -28,7 +28,6 @@ defmodule Mv.Membership.CustomField do
## Constraints ## Constraints
- Name must be unique across all custom fields - Name must be unique across all custom fields
- Name maximum length: 100 characters - Name maximum length: 100 characters
- `value_type` cannot be changed after creation (immutable)
- Deleting a custom field will cascade delete all associated custom field values - Deleting a custom field will cascade delete all associated custom field values
## Calculations ## Calculations
@ -60,7 +59,7 @@ defmodule Mv.Membership.CustomField do
end end
actions do actions do
defaults [:read] defaults [:read, :update]
default_accept [:name, :value_type, :description, :required, :show_in_overview] default_accept [:name, :value_type, :description, :required, :show_in_overview]
create :create do create :create do
@ -69,19 +68,6 @@ defmodule Mv.Membership.CustomField do
validate string_length(:slug, min: 1) validate string_length(:slug, min: 1)
end end
update :update do
accept [:name, :description, :required, :show_in_overview]
require_atomic? false
validate fn changeset, _context ->
if Ash.Changeset.changing_attribute?(changeset, :value_type) do
{:error, field: :value_type, message: "cannot be changed after creation"}
else
:ok
end
end
end
destroy :destroy_with_values do destroy :destroy_with_values do
primary? true primary? true
end end

View file

@ -333,10 +333,10 @@ defmodule Mv.Membership.Member do
authorize_if Mv.Authorization.Checks.HasPermission authorize_if Mv.Authorization.Checks.HasPermission
end end
# Internal sync action: only SystemActor may set vereinfacht_contact_id (used by SyncContact change). # Internal sync action: allow setting vereinfacht_contact_id (used only by SyncContact change).
policy action(:set_vereinfacht_contact_id) do policy action(:set_vereinfacht_contact_id) do
description "Only system actor may set Vereinfacht contact ID" description "Allow internal sync to set Vereinfacht contact ID"
authorize_if Mv.Authorization.Checks.ActorIsSystemUser authorize_if always()
end end
# CREATE/UPDATE: Forbid memberuser link unless admin, then check permissions # CREATE/UPDATE: Forbid memberuser link unless admin, then check permissions

View file

@ -72,8 +72,7 @@ defmodule Mv.Membership.Setting do
:default_membership_fee_type_id, :default_membership_fee_type_id,
:vereinfacht_api_url, :vereinfacht_api_url,
:vereinfacht_api_key, :vereinfacht_api_key,
:vereinfacht_club_id, :vereinfacht_club_id
:vereinfacht_app_url
] ]
end end
@ -88,8 +87,7 @@ defmodule Mv.Membership.Setting do
:default_membership_fee_type_id, :default_membership_fee_type_id,
:vereinfacht_api_url, :vereinfacht_api_url,
:vereinfacht_api_key, :vereinfacht_api_key,
:vereinfacht_club_id, :vereinfacht_club_id
:vereinfacht_app_url
] ]
end end
@ -253,13 +251,6 @@ defmodule Mv.Membership.Setting do
description "Vereinfacht club ID for multi-tenancy" description "Vereinfacht club ID for multi-tenancy"
end end
attribute :vereinfacht_app_url, :string do
allow_nil? true
public? true
description "Vereinfacht app base URL for contact view links (e.g. https://app.verein.visuel.dev)"
end
timestamps() timestamps()
end end

View file

@ -1,15 +0,0 @@
defmodule Mv.Authorization.Checks.ActorIsSystemUser do
@moduledoc """
Policy check: true only when the actor is the system user (e.g. system@mila.local).
Used to restrict internal actions (e.g. Member.set_vereinfacht_contact_id) so that
only code paths using SystemActor can perform them, not regular admins.
"""
use Ash.Policy.SimpleCheck
@impl true
def describe(_opts), do: "actor is the system user"
@impl true
def match?(actor, _context, _opts), do: Mv.Helpers.SystemActor.system_user?(actor)
end

View file

@ -178,37 +178,6 @@ defmodule Mv.Config do
env_or_setting("VEREINFACHT_CLUB_ID", :vereinfacht_club_id) env_or_setting("VEREINFACHT_CLUB_ID", :vereinfacht_club_id)
end end
@doc """
Returns the Vereinfacht app base URL for contact view links (frontend, not API).
Reads from `VEREINFACHT_APP_URL` env first, then from Settings.
Used to build links like https://app.verein.visuel.dev/en/admin/finances/contacts/{id}.
If not set, derived from API URL by replacing host \"api.\" with \"app.\" when possible.
"""
@spec vereinfacht_app_url() :: String.t() | nil
def vereinfacht_app_url do
env_or_setting("VEREINFACHT_APP_URL", :vereinfacht_app_url) ||
derive_app_url_from_api_url(vereinfacht_api_url())
end
defp derive_app_url_from_api_url(nil), do: nil
defp derive_app_url_from_api_url(api_url) when is_binary(api_url) do
api_url = String.trim(api_url)
uri = URI.parse(api_url)
host = uri.host || ""
if String.starts_with?(host, "api.") do
app_host = "app." <> String.slice(host, 4..-1//1)
scheme = uri.scheme || "https"
"#{scheme}://#{app_host}"
else
nil
end
end
defp derive_app_url_from_api_url(_), do: nil
@doc """ @doc """
Returns true if Vereinfacht is fully configured (URL, API key, and club ID all set). Returns true if Vereinfacht is fully configured (URL, API key, and club ID all set).
""" """
@ -242,11 +211,6 @@ defmodule Mv.Config do
""" """
def vereinfacht_club_id_env_set?, do: env_set?("VEREINFACHT_CLUB_ID") def vereinfacht_club_id_env_set?, do: env_set?("VEREINFACHT_CLUB_ID")
@doc """
Returns true if VEREINFACHT_APP_URL is set (field is read-only in Settings).
"""
def vereinfacht_app_url_env_set?, do: env_set?("VEREINFACHT_APP_URL")
defp env_set?(key) do defp env_set?(key) do
case System.get_env(key) do case System.get_env(key) do
nil -> false nil -> false
@ -277,22 +241,18 @@ defmodule Mv.Config do
end end
@doc """ @doc """
Returns the URL to view a finance contact in the Vereinfacht app (frontend). Returns the URL to view a finance contact (e.g. in Vereinfacht frontend or API).
Uses the configured app base URL (or derived from API URL) and appends Uses the configured API base URL and appends /finance-contacts/{id}.
/en/admin/finances/contacts/{id}. Returns nil if no app URL can be determined. Can be extended later with a dedicated frontend URL setting.
""" """
@spec vereinfacht_contact_view_url(String.t()) :: String.t() | nil @spec vereinfacht_contact_view_url(String.t()) :: String.t() | nil
def vereinfacht_contact_view_url(contact_id) when is_binary(contact_id) do def vereinfacht_contact_view_url(contact_id) when is_binary(contact_id) do
base = vereinfacht_app_url() base = vereinfacht_api_url()
if present?(base) do if present?(base),
base do: base |> String.trim_trailing("/") |> then(&"#{&1}/finance-contacts/#{contact_id}"),
|> String.trim_trailing("/") else: nil
|> then(&"#{&1}/en/admin/finances/contacts/#{contact_id}")
else
nil
end
end end
defp present?(nil), do: false defp present?(nil), do: false

View file

@ -16,7 +16,7 @@ defmodule Mv.Membership.MemberExport do
alias MvWeb.MemberLive.Index.MembershipFeeStatus alias MvWeb.MemberLive.Index.MembershipFeeStatus
@member_fields_allowlist (Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)) ++ @member_fields_allowlist (Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)) ++
["membership_fee_status", "groups"] ["membership_fee_status"]
@computed_export_fields ["membership_fee_status"] @computed_export_fields ["membership_fee_status"]
@computed_insert_after "membership_fee_start_date" @computed_insert_after "membership_fee_start_date"
@custom_field_prefix Mv.Constants.custom_field_prefix() @custom_field_prefix Mv.Constants.custom_field_prefix()
@ -323,14 +323,10 @@ defmodule Mv.Membership.MemberExport do
|> Enum.filter(&(&1 in @domain_member_field_strings)) |> Enum.filter(&(&1 in @domain_member_field_strings))
|> order_member_fields_like_table() |> order_member_fields_like_table()
# Separate groups from other fields (groups is handled as a special field, not a member field) # final member_fields list (used for column specs order): table order + computed inserted
groups_field = if "groups" in member_fields, do: ["groups"], else: []
# final member_fields list (used for column specs order): table order + computed inserted + groups
ordered_member_fields = ordered_member_fields =
selectable_member_fields selectable_member_fields
|> insert_computed_fields_like_table(computed_fields) |> insert_computed_fields_like_table(computed_fields)
|> then(fn fields -> fields ++ groups_field end)
%{ %{
selected_ids: filter_valid_uuids(extract_list(params, "selected_ids")), selected_ids: filter_valid_uuids(extract_list(params, "selected_ids")),

View file

@ -132,15 +132,12 @@ defmodule Mv.Membership.MemberExport.Build do
parsed.computed_fields != [] or parsed.computed_fields != [] or
"membership_fee_status" in parsed.member_fields "membership_fee_status" in parsed.member_fields
need_groups = "groups" in parsed.member_fields
query = query =
Member Member
|> Ash.Query.new() |> Ash.Query.new()
|> Ash.Query.select(select_fields) |> Ash.Query.select(select_fields)
|> load_custom_field_values_query(custom_field_ids_union) |> load_custom_field_values_query(custom_field_ids_union)
|> maybe_load_cycles(need_cycles, parsed.show_current_cycle) |> maybe_load_cycles(need_cycles, parsed.show_current_cycle)
|> maybe_load_groups(need_groups)
query = query =
if parsed.selected_ids != [] do if parsed.selected_ids != [] do
@ -244,22 +241,16 @@ defmodule Mv.Membership.MemberExport.Build do
defp maybe_sort(query, _field, nil), do: {query, false} defp maybe_sort(query, _field, nil), do: {query, false}
defp maybe_sort(query, field, order) when is_binary(field) do defp maybe_sort(query, field, order) when is_binary(field) do
cond do if custom_field_sort?(field) do
field == "groups" -> {query, true}
# Groups sort → in-memory nach dem Read (wie Tabelle) else
{query, true} field_atom = String.to_existing_atom(field)
custom_field_sort?(field) -> if field_atom in (Mv.Constants.member_fields() -- [:notes]) do
{query, true} {Ash.Query.sort(query, [{field_atom, String.to_existing_atom(order)}]), false}
else
true -> {query, false}
field_atom = String.to_existing_atom(field) end
if field_atom in (Mv.Constants.member_fields() -- [:notes]) do
{Ash.Query.sort(query, [{field_atom, String.to_existing_atom(order)}]), false}
else
{query, false}
end
end end
rescue rescue
ArgumentError -> {query, false} ArgumentError -> {query, false}
@ -269,25 +260,11 @@ defmodule Mv.Membership.MemberExport.Build do
do: [] do: []
defp sort_members_by_custom_field(members, field, order, custom_fields) when is_binary(field) do defp sort_members_by_custom_field(members, field, order, custom_fields) when is_binary(field) do
if field == "groups" do
sort_members_by_groups_export(members, order)
else
sort_by_custom_field_value(members, field, order, custom_fields)
end
end
defp sort_by_custom_field_value(members, field, order, custom_fields) do
id_str = String.trim_leading(field, @custom_field_prefix) id_str = String.trim_leading(field, @custom_field_prefix)
custom_field = Enum.find(custom_fields, fn cf -> to_string(cf.id) == id_str end) custom_field = Enum.find(custom_fields, fn cf -> to_string(cf.id) == id_str end)
if is_nil(custom_field) do if is_nil(custom_field), do: members
members
else
sort_members_with_custom_field(members, custom_field, order)
end
end
defp sort_members_with_custom_field(members, custom_field, order) do
key_fn = fn member -> key_fn = fn member ->
cfv = find_cfv(member, custom_field) cfv = find_cfv(member, custom_field)
raw = if cfv, do: cfv.value, else: nil raw = if cfv, do: cfv.value, else: nil
@ -300,26 +277,6 @@ defmodule Mv.Membership.MemberExport.Build do
|> Enum.map(fn {m, _} -> m end) |> Enum.map(fn {m, _} -> m end)
end end
defp sort_members_by_groups_export(members, order) do
# Members with groups first, then by first group name alphabetically (min = first by sort order)
# Match table behavior from MvWeb.MemberLive.Index.sort_members_by_groups/2
first_group_name = fn member ->
(member.groups || [])
|> Enum.map(& &1.name)
|> Enum.min(fn -> nil end)
end
members
|> Enum.sort_by(fn member ->
name = first_group_name.(member)
# Nil (no groups) sorts last in asc, first in desc
{name == nil, name || ""}
end)
|> then(fn list ->
if order == "desc", do: Enum.reverse(list), else: list
end)
end
defp find_cfv(member, custom_field) do defp find_cfv(member, custom_field) do
(member.custom_field_values || []) (member.custom_field_values || [])
|> Enum.find(fn cfv -> |> Enum.find(fn cfv ->
@ -337,13 +294,6 @@ defmodule Mv.Membership.MemberExport.Build do
MembershipFeeStatus.load_cycles_for_members(query, show_current) MembershipFeeStatus.load_cycles_for_members(query, show_current)
end end
defp maybe_load_groups(query, false), do: query
defp maybe_load_groups(query, true) do
# Load groups with id and name only (for export formatting)
Ash.Query.load(query, groups: [:id, :name])
end
defp apply_cycle_status_filter(members, nil, _show_current), do: members defp apply_cycle_status_filter(members, nil, _show_current), do: members
defp apply_cycle_status_filter(members, status, show_current) when status in [:paid, :unpaid] do defp apply_cycle_status_filter(members, status, show_current) when status in [:paid, :unpaid] do
@ -393,19 +343,6 @@ defmodule Mv.Membership.MemberExport.Build do
} }
end) end)
groups_col =
if "groups" in parsed.member_fields do
[
%{
key: :groups,
kind: :groups,
label: label_fn.(:groups)
}
]
else
[]
end
custom_cols = custom_cols =
parsed.custom_field_ids parsed.custom_field_ids
|> Enum.map(fn id -> |> Enum.map(fn id ->
@ -424,7 +361,7 @@ defmodule Mv.Membership.MemberExport.Build do
end) end)
|> Enum.reject(&is_nil/1) |> Enum.reject(&is_nil/1)
member_cols ++ computed_cols ++ groups_col ++ custom_cols member_cols ++ computed_cols ++ custom_cols
end end
defp build_rows(members, columns, custom_fields_by_id) do defp build_rows(members, columns, custom_fields_by_id) do
@ -454,11 +391,6 @@ defmodule Mv.Membership.MemberExport.Build do
if is_binary(value), do: value, else: "" if is_binary(value), do: value, else: ""
end end
defp cell_value(member, %{kind: :groups, key: :groups}, _custom_fields_by_id) do
groups = Map.get(member, :groups) || []
format_groups(groups)
end
defp key_to_atom(k) when is_atom(k), do: k defp key_to_atom(k) when is_atom(k), do: k
defp key_to_atom(k) when is_binary(k) do defp key_to_atom(k) when is_binary(k) do
@ -492,15 +424,6 @@ defmodule Mv.Membership.MemberExport.Build do
defp format_member_value(%NaiveDateTime{} = dt), do: NaiveDateTime.to_iso8601(dt) defp format_member_value(%NaiveDateTime{} = dt), do: NaiveDateTime.to_iso8601(dt)
defp format_member_value(value), do: to_string(value) defp format_member_value(value), do: to_string(value)
defp format_groups([]), do: ""
defp format_groups(groups) when is_list(groups) do
groups
|> Enum.map(fn group -> Map.get(group, :name) || "" end)
|> Enum.reject(&(&1 == ""))
|> Enum.join(", ")
end
defp build_meta(members) do defp build_meta(members) do
%{ %{
generated_at: DateTime.utc_now() |> DateTime.to_iso8601(), generated_at: DateTime.utc_now() |> DateTime.to_iso8601(),

View file

@ -59,11 +59,6 @@ defmodule Mv.Membership.MembersCSV do
if is_binary(value), do: value, else: "" if is_binary(value), do: value, else: ""
end end
defp cell_value(member, %{kind: :groups, key: :groups}) do
groups = Map.get(member, :groups) || []
format_groups(groups)
end
defp key_to_atom(k) when is_atom(k), do: k defp key_to_atom(k) when is_atom(k), do: k
defp key_to_atom(k) when is_binary(k) do defp key_to_atom(k) when is_binary(k) do
@ -102,13 +97,4 @@ defmodule Mv.Membership.MembersCSV do
defp format_member_value(%DateTime{} = dt), do: DateTime.to_iso8601(dt) defp format_member_value(%DateTime{} = dt), do: DateTime.to_iso8601(dt)
defp format_member_value(%NaiveDateTime{} = dt), do: NaiveDateTime.to_iso8601(dt) defp format_member_value(%NaiveDateTime{} = dt), do: NaiveDateTime.to_iso8601(dt)
defp format_member_value(value), do: to_string(value) defp format_member_value(value), do: to_string(value)
defp format_groups([]), do: ""
defp format_groups(groups) when is_list(groups) do
groups
|> Enum.map(fn group -> Map.get(group, :name) || "" end)
|> Enum.reject(&(&1 == ""))
|> Enum.join(", ")
end
end end

View file

@ -7,57 +7,20 @@ defmodule Mv.Vereinfacht.Changes.SyncContact do
Runs in `after_transaction` so the member is persisted first. API failures are logged Runs in `after_transaction` so the member is persisted first. API failures are logged
but do not block the member operation. Requires Vereinfacht to be configured but do not block the member operation. Requires Vereinfacht to be configured
(Mv.Config.vereinfacht_configured?/0). (Mv.Config.vereinfacht_configured?/0).
Only runs when relevant data changed: on create always; on update only when
first_name, last_name, email, street, house_number, postal_code, or city changed,
or when the member has no vereinfacht_contact_id yet (to avoid unnecessary API calls).
""" """
use Ash.Resource.Change use Ash.Resource.Change
require Logger require Logger
@synced_attributes [
:first_name,
:last_name,
:email,
:street,
:house_number,
:postal_code,
:city
]
@impl true @impl true
def change(changeset, _opts, _context) do def change(changeset, _opts, _context) do
if Mv.Config.vereinfacht_configured?() and sync_relevant?(changeset) do if Mv.Config.vereinfacht_configured?() do
Ash.Changeset.after_transaction(changeset, &sync_after_transaction/2) Ash.Changeset.after_transaction(changeset, &sync_after_transaction/2)
else else
changeset changeset
end end
end end
defp sync_relevant?(changeset) do
case changeset.action_type do
:create -> true
:update -> relevant_update?(changeset)
_ -> false
end
end
defp relevant_update?(changeset) do
any_synced_attr_changed? =
Enum.any?(@synced_attributes, &Ash.Changeset.changing_attribute?(changeset, &1))
record = changeset.data
no_contact_id_yet? = record && blank_contact_id?(record.vereinfacht_contact_id)
any_synced_attr_changed? or no_contact_id_yet?
end
defp blank_contact_id?(nil), do: true
defp blank_contact_id?(""), do: true
defp blank_contact_id?(s) when is_binary(s), do: String.trim(s) == ""
defp blank_contact_id?(_), do: false
# Ash calls after_transaction with (changeset, result) only - 2 args. # Ash calls after_transaction with (changeset, result) only - 2 args.
defp sync_after_transaction(_changeset, {:ok, member}) do defp sync_after_transaction(_changeset, {:ok, member}) do
case Mv.Vereinfacht.sync_member(member) do case Mv.Vereinfacht.sync_member(member) do

View file

@ -163,16 +163,7 @@ defmodule Mv.Vereinfacht.Client do
defp find_contact_id_by_email_in_list(%{"data" => list}, normalized) when is_list(list) do defp find_contact_id_by_email_in_list(%{"data" => list}, normalized) when is_list(list) do
Enum.find_value(list, fn Enum.find_value(list, fn
%{"id" => id, "attributes" => %{"email" => att_email, "isExternal" => true}} %{"id" => id, "attributes" => %{"email" => att_email}} when is_binary(att_email) ->
when is_binary(att_email) ->
if att_email |> String.trim() |> String.downcase() == normalized do
normalize_contact_id(id)
else
nil
end
%{"id" => id, "attributes" => %{"email" => att_email, "isExternal" => "true"}}
when is_binary(att_email) ->
if att_email |> String.trim() |> String.downcase() == normalized do if att_email |> String.trim() |> String.downcase() == normalized do
normalize_contact_id(id) normalize_contact_id(id)
else else
@ -200,34 +191,16 @@ defmodule Mv.Vereinfacht.Client do
""" """
@spec get_contact(String.t()) :: {:ok, map()} | {:error, term()} @spec get_contact(String.t()) :: {:ok, map()} | {:error, term()}
def get_contact(contact_id) when is_binary(contact_id) do def get_contact(contact_id) when is_binary(contact_id) do
fetch_contact(contact_id, [])
end
@doc """
Fetches a finance contact with receipts (GET /finance-contacts/:id?include=receipts).
Returns {:ok, receipts} where receipts is a list of maps with :id and :attributes
(and optional :type) for each receipt, or {:error, reason}.
"""
@spec get_contact_with_receipts(String.t()) :: {:ok, [map()]} | {:error, term()}
def get_contact_with_receipts(contact_id) when is_binary(contact_id) do
case fetch_contact(contact_id, include: "receipts") do
{:ok, body} -> {:ok, extract_receipts_from_response(body)}
{:error, _} = err -> err
end
end
defp fetch_contact(contact_id, query_params) do
base_url = base_url() base_url = base_url()
api_key = api_key() api_key = api_key()
if is_nil(base_url) or is_nil(api_key) do if is_nil(base_url) or is_nil(api_key) do
{:error, :not_configured} {:error, :not_configured}
else else
path = url =
base_url |> String.trim_trailing("/") |> then(&"#{&1}/finance-contacts/#{contact_id}") base_url
|> String.trim_trailing("/")
url = build_url_with_params(path, query_params) |> then(&"#{&1}/finance-contacts/#{contact_id}")
case Req.get(url, [headers: headers(api_key)] ++ req_http_options()) do case Req.get(url, [headers: headers(api_key)] ++ req_http_options()) do
{:ok, %{status: 200, body: body}} when is_map(body) -> {:ok, %{status: 200, body: body}} when is_map(body) ->
@ -242,38 +215,6 @@ defmodule Mv.Vereinfacht.Client do
end end
end end
defp build_url_with_params(base, []), do: base
defp build_url_with_params(base, include: value) do
sep = if String.contains?(base, "?"), do: "&", else: "?"
base <> sep <> "include=" <> URI.encode(value, &URI.char_unreserved?/1)
end
defp extract_receipts_from_response(%{"included" => included}) when is_list(included) do
included
|> Enum.filter(&match?(%{"type" => "receipts"}, &1))
|> Enum.map(fn %{"id" => id, "attributes" => attrs} = r ->
Map.merge(%{id: id, type: r["type"]}, string_keys_to_atoms(attrs || %{}))
end)
end
defp extract_receipts_from_response(_), do: []
defp string_keys_to_atoms(map) when is_map(map) do
Map.new(map, fn {k, v} -> {to_atom_key(k), v} end)
end
defp to_atom_key(k) when is_binary(k) do
try do
String.to_existing_atom(k)
rescue
ArgumentError -> String.to_atom(k)
end
end
defp to_atom_key(k) when is_atom(k), do: k
defp to_atom_key(k), do: to_atom_key(to_string(k))
defp base_url, do: Mv.Config.vereinfacht_api_url() defp base_url, do: Mv.Config.vereinfacht_api_url()
defp api_key, do: Mv.Config.vereinfacht_api_key() defp api_key, do: Mv.Config.vereinfacht_api_key()
defp club_id, do: Mv.Config.vereinfacht_club_id() defp club_id, do: Mv.Config.vereinfacht_club_id()
@ -329,7 +270,6 @@ defmodule Mv.Vereinfacht.Client do
|> put_attr("zipCode", member |> Map.get(:postal_code)) |> put_attr("zipCode", member |> Map.get(:postal_code))
|> put_attr("city", member |> Map.get(:city)) |> put_attr("city", member |> Map.get(:city))
|> Map.put("contactType", "person") |> Map.put("contactType", "person")
|> Map.put("isExternal", true)
|> Enum.reject(fn {_k, v} -> is_nil(v) end) |> Enum.reject(fn {_k, v} -> is_nil(v) end)
|> Map.new() |> Map.new()
end end

View file

@ -35,8 +35,6 @@ defmodule Mv.Vereinfacht.SyncFlash do
@doc false @doc false
def create_table! do def create_table! do
# :public so any process can write (SyncContact runs in LiveView/Ash transaction process,
# not the process that created the table). :protected would restrict writes to the creating process.
if :ets.whereis(@table) == :undefined do if :ets.whereis(@table) == :undefined do
:ets.new(@table, [:set, :public, :named_table]) :ets.new(@table, [:set, :public, :named_table])
end end

View file

@ -45,11 +45,4 @@ defmodule MvWeb.AuthOverrides do
Gettext.gettext(MvWeb.Gettext, "or") Gettext.gettext(MvWeb.Gettext, "or")
end) end)
end end
# Hide AshAuthentication's Flash component since we use flash_group in root layout
# This prevents duplicate flash messages
override AshAuthentication.Phoenix.Components.Flash do
set :message_class_info, "hidden"
set :message_class_error, "hidden"
end
end end

View file

@ -15,98 +15,24 @@
</script> </script>
<script> <script>
(() => { (() => {
const mq = window.matchMedia("(prefers-color-scheme: dark)"); const setTheme = (theme) => {
const systemTheme = () => (mq.matches ? "dark" : "light"); if (theme === "system") {
localStorage.removeItem("phx:theme");
// Single source of truth: document.documentElement.removeAttribute("data-theme");
// - localStorage["phx:theme"] = "light" | "dark" (explicit override) } else {
// - missing key => "system" localStorage.setItem("phx:theme", theme);
const storedTheme = () => localStorage.getItem("phx:theme") || "system"; document.documentElement.setAttribute("data-theme", theme);
const effectiveTheme = (t) => (t === "system" ? systemTheme() : t);
const applyThemeNow = (t) => {
document.documentElement.setAttribute("data-theme", effectiveTheme(t));
};
const syncToggle = () => {
const eff = effectiveTheme(storedTheme());
document.querySelectorAll("[data-theme-toggle]").forEach((el) => {
el.checked = eff === "dark";
});
};
const setTheme = (t) => {
if (t === "system") localStorage.removeItem("phx:theme");
else localStorage.setItem("phx:theme", t);
applyThemeNow(t);
syncToggle(); // if toggle exists already
};
// 1) Apply theme ASAP to match system on first paint
applyThemeNow(storedTheme());
// 2) Sync toggle once DOM is ready (fixes initial "light" toggle)
document.addEventListener("DOMContentLoaded", syncToggle);
// 3) If toggle appears later (LiveView render), sync immediately
const obs = new MutationObserver(() => {
if (document.querySelector("[data-theme-toggle]")) syncToggle();
});
obs.observe(document.documentElement, { childList: true, subtree: true });
window.addEventListener("phx:set-theme", ({ detail: { theme } }) => setTheme(theme));
window.addEventListener("storage", (e) => e.key === "phx:theme" && setTheme(e.newValue || "system"));
mq.addEventListener("change", () => {
if (localStorage.getItem("phx:theme") === null) {
applyThemeNow("system");
syncToggle();
} }
}); };
if (!document.documentElement.hasAttribute("data-theme")) {
setTheme(localStorage.getItem("phx:theme") || "system");
}
window.addEventListener("storage", (e) => e.key === "phx:theme" && setTheme(e.newValue || "system"));
window.addEventListener("phx:set-theme", ({ detail: { theme } }) => setTheme(theme));
})(); })();
</script> </script>
</head> </head>
<body> <body>
<div
id="flash-group-root"
aria-live="polite"
class="z-50 flex flex-col gap-2 toast toast-top toast-end"
>
<.flash id="flash-success-root" kind={:success} flash={@flash} />
<.flash id="flash-warning-root" kind={:warning} flash={@flash} />
<.flash id="flash-info-root" kind={:info} flash={@flash} />
<.flash id="flash-error-root" kind={:error} flash={@flash} />
<.flash
id="client-error-root"
kind={:error}
title={gettext("We can't find the internet")}
phx-disconnected={
show(".phx-client-error #client-error-root") |> JS.remove_attribute("hidden")
}
phx-connected={hide("#client-error-root") |> JS.set_attribute({"hidden", ""})}
hidden
>
{gettext("Attempting to reconnect")}
<.icon name="hero-arrow-path" class="ml-1 size-3 motion-safe:animate-spin" />
</.flash>
<.flash
id="server-error-root"
kind={:error}
title={gettext("Something went wrong!")}
phx-disconnected={
show(".phx-server-error #server-error-root") |> JS.remove_attribute("hidden")
}
phx-connected={hide("#server-error-root") |> JS.set_attribute({"hidden", ""})}
hidden
>
{gettext("Attempting to reconnect")}
<.icon name="hero-arrow-path" class="ml-1 size-3 motion-safe:animate-spin" />
</.flash>
</div>
{@inner_content} {@inner_content}
</body> </body>
</html> </html>

View file

@ -248,17 +248,12 @@ defmodule MvWeb.Layouts.Sidebar do
aria-label={gettext("Toggle dark mode")} aria-label={gettext("Toggle dark mode")}
> >
<.icon name="hero-sun" class="size-5" aria-hidden="true" /> <.icon name="hero-sun" class="size-5" aria-hidden="true" />
<div id="theme-toggle" phx-update="ignore"> <input
<input type="checkbox"
id="theme-toggle-input" value="dark"
type="checkbox" class="toggle toggle-sm theme-controller focus:outline-none"
class="toggle toggle-sm focus:outline-none" aria-label={gettext("Toggle dark mode")}
data-theme-toggle />
onchange="window.dispatchEvent(new CustomEvent('phx:set-theme',{detail:{theme:this.checked?'dark':'light'}}))"
aria-label={gettext("Toggle dark mode")}
/>
</div>
<.icon name="hero-moon" class="size-5" aria-hidden="true" /> <.icon name="hero-moon" class="size-5" aria-hidden="true" />
</label> </label>
""" """

View file

@ -45,7 +45,9 @@ defmodule MvWeb.AuthController do
- Generic authentication failures - Generic authentication failures
""" """
def failure(conn, activity, reason) do def failure(conn, activity, reason) do
log_failure_safely(activity, reason) Logger.warning(
"Authentication failure - Activity: #{inspect(activity)}, Reason: #{inspect(reason)}"
)
case {activity, reason} do case {activity, reason} do
{{:rauthy, _action}, reason} -> {{:rauthy, _action}, reason} ->
@ -55,70 +57,10 @@ defmodule MvWeb.AuthController do
handle_authentication_failed(conn, caused_by) handle_authentication_failed(conn, caused_by)
_ -> _ ->
conn redirect_with_error(conn, gettext("Incorrect email or password"))
|> put_flash(:error, gettext("Incorrect email or password"))
|> redirect(to: ~p"/sign-in")
end end
end end
# Log authentication failures safely, avoiding sensitive data for {:rauthy, _} activities
defp log_failure_safely({:rauthy, _action} = activity, reason) do
# For Assent errors, use safe_assent_meta to avoid logging tokens/URLs with query params
case reason do
%Assent.ServerUnreachableError{} = err ->
meta = safe_assent_meta(err)
message = format_safe_log_message("Authentication failure", activity, meta)
Logger.warning(message)
%Assent.InvalidResponseError{} = err ->
meta = safe_assent_meta(err)
message = format_safe_log_message("Authentication failure", activity, meta)
Logger.warning(message)
_ ->
# For other rauthy errors, log only error type, not full details
error_type = get_error_type(reason)
Logger.warning(
"Authentication failure - Activity: #{inspect(activity)}, Error type: #{error_type}"
)
end
end
defp log_failure_safely(activity, reason) do
# For non-rauthy activities, safe to log full reason
Logger.warning(
"Authentication failure - Activity: #{inspect(activity)}, Reason: #{inspect(reason)}"
)
end
# Extract safe error type identifier without sensitive data
defp get_error_type(%struct{}), do: "#{struct}"
defp get_error_type(atom) when is_atom(atom), do: inspect(atom)
defp get_error_type(_other), do: "[redacted]"
# Format safe log message with metadata included in the message string
defp format_safe_log_message(base_message, activity, meta) when is_list(meta) do
activity_str = "Activity: #{inspect(activity)}"
meta_str = format_meta_string(meta)
"#{base_message} - #{activity_str}#{meta_str}"
end
defp format_meta_string([]), do: ""
defp format_meta_string(meta) when is_list(meta) do
parts =
Enum.map(meta, fn
{:request_url, url} -> "Request URL: #{url}"
{:status, status} -> "Status: #{status}"
{:http_adapter, adapter} -> "HTTP Adapter: #{inspect(adapter)}"
_ -> nil
end)
|> Enum.filter(&(&1 != nil))
if Enum.empty?(parts), do: "", else: " - " <> Enum.join(parts, ", ")
end
# Handle all Rauthy (OIDC) authentication failures # Handle all Rauthy (OIDC) authentication failures
defp handle_rauthy_failure(conn, %Ash.Error.Invalid{errors: errors}) do defp handle_rauthy_failure(conn, %Ash.Error.Invalid{errors: errors}) do
handle_oidc_email_collision(conn, errors) handle_oidc_email_collision(conn, errors)
@ -132,46 +74,14 @@ defmodule MvWeb.AuthController do
handle_oidc_email_collision(conn, errors) handle_oidc_email_collision(conn, errors)
_ -> _ ->
conn redirect_with_error(conn, gettext("Unable to authenticate with OIDC. Please try again."))
|> put_flash(:error, gettext("Unable to authenticate with OIDC. Please try again."))
|> redirect(to: ~p"/sign-in")
end end
end end
# Handle Assent server unreachable errors (network/connectivity issues)
defp handle_rauthy_failure(conn, %Assent.ServerUnreachableError{} = _err) do
# Logging already done safely in failure/3 via log_failure_safely/2
# No need to log again here to avoid duplicate logs
conn
|> put_flash(
:error,
gettext("The authentication server is currently unavailable. Please try again later.")
)
|> redirect(to: ~p"/sign-in")
end
# Handle Assent invalid response errors (configuration or malformed responses)
defp handle_rauthy_failure(conn, %Assent.InvalidResponseError{} = _err) do
# Logging already done safely in failure/3 via log_failure_safely/2
# No need to log again here to avoid duplicate logs
conn
|> put_flash(
:error,
gettext("Authentication configuration error. Please contact the administrator.")
)
|> redirect(to: ~p"/sign-in")
end
# Catch-all clause for any other error types # Catch-all clause for any other error types
defp handle_rauthy_failure(conn, _reason) do defp handle_rauthy_failure(conn, reason) do
# Logging already done safely in failure/3 via log_failure_safely/2 Logger.warning("Unhandled Rauthy failure reason: #{inspect(reason)}")
# No need to log again here to avoid duplicate logs redirect_with_error(conn, gettext("Unable to authenticate with OIDC. Please try again."))
conn
|> put_flash(:error, gettext("Unable to authenticate with OIDC. Please try again."))
|> redirect(to: ~p"/sign-in")
end end
# Handle generic AuthenticationFailed errors # Handle generic AuthenticationFailed errors
@ -183,20 +93,14 @@ defmodule MvWeb.AuthController do
You can confirm your account using the link we sent to you, or by resetting your password. You can confirm your account using the link we sent to you, or by resetting your password.
""") """)
conn redirect_with_error(conn, message)
|> put_flash(:error, message)
|> redirect(to: ~p"/sign-in")
else else
conn redirect_with_error(conn, gettext("Authentication failed. Please try again."))
|> put_flash(:error, gettext("Authentication failed. Please try again."))
|> redirect(to: ~p"/sign-in")
end end
end end
defp handle_authentication_failed(conn, _other) do defp handle_authentication_failed(conn, _other) do
conn redirect_with_error(conn, gettext("Authentication failed. Please try again."))
|> put_flash(:error, gettext("Authentication failed. Please try again."))
|> redirect(to: ~p"/sign-in")
end end
# Handle OIDC email collision - user needs to verify password to link accounts # Handle OIDC email collision - user needs to verify password to link accounts
@ -208,10 +112,7 @@ defmodule MvWeb.AuthController do
nil -> nil ->
# Check if it's a "different OIDC account" error or email uniqueness error # Check if it's a "different OIDC account" error or email uniqueness error
error_message = extract_meaningful_error_message(errors) error_message = extract_meaningful_error_message(errors)
redirect_with_error(conn, error_message)
conn
|> put_flash(:error, error_message)
|> redirect(to: ~p"/sign-in")
end end
end end
@ -276,47 +177,13 @@ defmodule MvWeb.AuthController do
|> redirect(to: ~p"/auth/link-oidc-account") |> redirect(to: ~p"/auth/link-oidc-account")
end end
# Extract safe metadata from Assent errors for logging # Generic error redirect helper
# Never logs sensitive data: no tokens, secrets, or full request URLs defp redirect_with_error(conn, message) do
# Returns keyword list for Logger.warning/2 conn
defp safe_assent_meta(%{request_url: url} = err) when is_binary(url) do |> put_flash(:error, message)
[ |> redirect(to: ~p"/sign-in")
request_url: redact_url(url),
http_adapter: Map.get(err, :http_adapter)
]
|> Enum.filter(fn {_key, value} -> not is_nil(value) end)
end end
# Handle InvalidResponseError which has :response field (HTTPResponse struct)
defp safe_assent_meta(%{response: %{status: status} = response} = err) do
[
status: status,
http_adapter: Map.get(response, :http_adapter) || Map.get(err, :http_adapter)
]
|> Enum.filter(fn {_key, value} -> not is_nil(value) end)
end
defp safe_assent_meta(err) do
# Only extract safe, simple fields
[
http_adapter: Map.get(err, :http_adapter)
]
|> Enum.filter(fn {_key, value} -> not is_nil(value) end)
end
# Redact URL to only show scheme and host, hiding path, query, and fragments
defp redact_url(url) when is_binary(url) do
case URI.parse(url) do
%URI{scheme: scheme, host: host} when not is_nil(scheme) and not is_nil(host) ->
"#{scheme}://#{host}"
_ ->
"[redacted]"
end
end
defp redact_url(_), do: "[redacted]"
def sign_out(conn, _params) do def sign_out(conn, _params) do
return_to = get_session(conn, :return_to) || ~p"/" return_to = get_session(conn, :return_to) || ~p"/"

View file

@ -18,8 +18,7 @@ defmodule MvWeb.MemberExportController do
alias MvWeb.MemberLive.Index.MembershipFeeStatus alias MvWeb.MemberLive.Index.MembershipFeeStatus
use Gettext, backend: MvWeb.Gettext use Gettext, backend: MvWeb.Gettext
@member_fields_allowlist (Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)) ++ @member_fields_allowlist Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)
["groups"]
@computed_export_fields ["membership_fee_status"] @computed_export_fields ["membership_fee_status"]
@custom_field_prefix Mv.Constants.custom_field_prefix() @custom_field_prefix Mv.Constants.custom_field_prefix()
@ -84,7 +83,6 @@ defmodule MvWeb.MemberExportController do
domain_fields = Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1) domain_fields = Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)
selectable = Enum.filter(member_fields, fn f -> f in domain_fields end) selectable = Enum.filter(member_fields, fn f -> f in domain_fields end)
computed = Enum.filter(member_fields, fn f -> f in @computed_export_fields end) computed = Enum.filter(member_fields, fn f -> f in @computed_export_fields end)
# "groups" is neither a domain field nor a computed field, it's handled separately
{selectable, computed} {selectable, computed}
end end
@ -237,15 +235,12 @@ defmodule MvWeb.MemberExportController do
need_cycles = need_cycles =
parsed.computed_fields != [] and "membership_fee_status" in parsed.computed_fields parsed.computed_fields != [] and "membership_fee_status" in parsed.computed_fields
need_groups = "groups" in parsed.member_fields
query = query =
Member Member
|> Ash.Query.new() |> Ash.Query.new()
|> Ash.Query.select(select_fields) |> Ash.Query.select(select_fields)
|> load_custom_field_values_query(parsed.custom_field_ids) |> load_custom_field_values_query(parsed.custom_field_ids)
|> maybe_load_cycles(need_cycles, parsed.show_current_cycle) |> maybe_load_cycles(need_cycles, parsed.show_current_cycle)
|> maybe_load_groups(need_groups)
query = query =
if parsed.selected_ids != [] do if parsed.selected_ids != [] do
@ -289,13 +284,6 @@ defmodule MvWeb.MemberExportController do
MembershipFeeStatus.load_cycles_for_members(query, show_current) MembershipFeeStatus.load_cycles_for_members(query, show_current)
end end
defp maybe_load_groups(query, false), do: query
defp maybe_load_groups(query, true) do
# Load groups with id and name only (for export formatting)
Ash.Query.load(query, groups: [:id, :name])
end
# Adds computed field values to members (e.g. membership_fee_status) # Adds computed field values to members (e.g. membership_fee_status)
defp add_computed_fields(members, computed_fields, show_current_cycle) do defp add_computed_fields(members, computed_fields, show_current_cycle) do
if "membership_fee_status" in computed_fields do if "membership_fee_status" in computed_fields do
@ -341,23 +329,17 @@ defmodule MvWeb.MemberExportController do
defp maybe_sort_export(query, _field, nil), do: {query, false} defp maybe_sort_export(query, _field, nil), do: {query, false}
defp maybe_sort_export(query, field, order) when is_binary(field) do defp maybe_sort_export(query, field, order) when is_binary(field) do
cond do if custom_field_sort?(field) do
field == "groups" -> # Custom field sort → in-memory nach dem Read (wie Tabelle)
# Groups sort → in-memory nach dem Read (wie Tabelle) {query, true}
{query, true} else
field_atom = String.to_existing_atom(field)
custom_field_sort?(field) -> if field_atom in (Mv.Constants.member_fields() -- [:notes]) do
# Custom field sort → in-memory nach dem Read (wie Tabelle) {Ash.Query.sort(query, [{field_atom, String.to_existing_atom(order)}]), false}
{query, true} else
{query, false}
true -> end
field_atom = String.to_existing_atom(field)
if field_atom in (Mv.Constants.member_fields() -- [:notes]) do
{Ash.Query.sort(query, [{field_atom, String.to_existing_atom(order)}]), false}
else
{query, false}
end
end end
rescue rescue
ArgumentError -> {query, false} ArgumentError -> {query, false}
@ -376,15 +358,6 @@ defmodule MvWeb.MemberExportController do
defp sort_members_by_custom_field_export(members, field, order, custom_fields) defp sort_members_by_custom_field_export(members, field, order, custom_fields)
when is_binary(field) do when is_binary(field) do
order = order || "asc" order = order || "asc"
if field == "groups" do
sort_members_by_groups_export(members, order)
else
sort_by_custom_field_value(members, field, order, custom_fields)
end
end
defp sort_by_custom_field_value(members, field, order, custom_fields) do
id_str = String.trim_leading(field, @custom_field_prefix) id_str = String.trim_leading(field, @custom_field_prefix)
custom_field = custom_field =
@ -414,26 +387,6 @@ defmodule MvWeb.MemberExportController do
end end
end end
defp sort_members_by_groups_export(members, order) do
# Members with groups first, then by first group name alphabetically (min = first by sort order)
# Match table behavior from MvWeb.MemberLive.Index.sort_members_by_groups/2
first_group_name = fn member ->
(member.groups || [])
|> Enum.map(& &1.name)
|> Enum.min(fn -> nil end)
end
members
|> Enum.sort_by(fn member ->
name = first_group_name.(member)
# Nil (no groups) sorts last in asc, first in desc
{name == nil, name || ""}
end)
|> then(fn list ->
if order == "desc", do: Enum.reverse(list), else: list
end)
end
defp has_non_empty_custom_field_value?(member, custom_field) do defp has_non_empty_custom_field_value?(member, custom_field) do
case find_cfv(member, custom_field) do case find_cfv(member, custom_field) do
nil -> nil ->
@ -488,19 +441,6 @@ defmodule MvWeb.MemberExportController do
} }
end) end)
groups_col =
if "groups" in parsed.member_fields do
[
%{
header: groups_field_header(conn),
kind: :groups,
key: :groups
}
]
else
[]
end
custom_cols = custom_cols =
parsed.custom_field_ids parsed.custom_field_ids
|> Enum.map(fn id -> |> Enum.map(fn id ->
@ -519,7 +459,7 @@ defmodule MvWeb.MemberExportController do
end) end)
|> Enum.reject(&is_nil/1) |> Enum.reject(&is_nil/1)
member_cols ++ computed_cols ++ groups_col ++ custom_cols member_cols ++ computed_cols ++ custom_cols
end end
# --- headers: use MemberFields.label for translations --- # --- headers: use MemberFields.label for translations ---
@ -559,10 +499,6 @@ defmodule MvWeb.MemberExportController do
cf.name cf.name
end end
defp groups_field_header(_conn) do
MemberFields.label(:groups)
end
defp humanize_field(str) do defp humanize_field(str) do
str str
|> String.replace("_", " ") |> String.replace("_", " ")

View file

@ -26,6 +26,7 @@ defmodule MvWeb.Components.SortHeaderComponent do
class="btn btn-ghost select-none" class="btn btn-ghost select-none"
phx-click="sort" phx-click="sort"
phx-value-field={@field} phx-value-field={@field}
phx-target={@myself}
data-testid={@field} data-testid={@field}
> >
{@label} {@label}
@ -42,6 +43,12 @@ defmodule MvWeb.Components.SortHeaderComponent do
""" """
end end
@impl true
def handle_event("sort", %{"field" => field_str}, socket) do
send(self(), {:sort, field_str})
{:noreply, socket}
end
# ------------------------------------------------- # -------------------------------------------------
# Hilfsfunktionen für ARIA Attribute & Icon SVG # Hilfsfunktionen für ARIA Attribute & Icon SVG
# ------------------------------------------------- # -------------------------------------------------

View file

@ -5,7 +5,7 @@ defmodule MvWeb.CustomFieldLive.FormComponent do
## Features ## Features
- Create new custom field definitions - Create new custom field definitions
- Edit existing custom fields - Edit existing custom fields
- Select value type from supported types (only on create; immutable after creation) - Select value type from supported types
- Set required flag - Set required flag
- Real-time validation - Real-time validation
@ -44,50 +44,15 @@ defmodule MvWeb.CustomFieldLive.FormComponent do
> >
<.input field={@form[:name]} type="text" label={gettext("Name")} /> <.input field={@form[:name]} type="text" label={gettext("Name")} />
<%= if @custom_field do %> <.input
<%!-- Show value_type as read-only input when editing (matches Member Field pattern) --%> field={@form[:value_type]}
<div type="select"
class="tooltip tooltip-right" label={gettext("Value type")}
data-tip={gettext("Value type cannot be changed after creation")} options={
aria-label={gettext("Value type cannot be changed after creation")} Ash.Resource.Info.attribute(Mv.Membership.CustomField, :value_type).constraints[:one_of]
> |> Enum.map(fn type -> {MvWeb.Translations.FieldTypes.label(type), type} end)
<fieldset class="mb-2 fieldset"> }
<label> />
<span class="mb-1 label flex items-center gap-2">
{gettext("Value type")}
<.icon
name="hero-information-circle"
class="w-4 h-4 text-base-content/60 cursor-help"
aria-hidden="true"
/>
</span>
<input
type="text"
name={@form[:value_type].name}
id={@form[:value_type].id}
value={MvWeb.Translations.FieldTypes.label(@custom_field.value_type)}
disabled
readonly
class="w-full input"
/>
</label>
</fieldset>
</div>
<% else %>
<%!-- Show value_type as select when creating --%>
<.input
field={@form[:value_type]}
type="select"
label={gettext("Value type")}
options={
Ash.Resource.Info.attribute(Mv.Membership.CustomField, :value_type).constraints[
:one_of
]
|> Enum.map(fn type -> {MvWeb.Translations.FieldTypes.label(type), type} end)
}
/>
<% end %>
<.input field={@form[:description]} type="text" label={gettext("Description")} /> <.input field={@form[:description]} type="text" label={gettext("Description")} />
<.input field={@form[:required]} type="checkbox" label={gettext("Required")} /> <.input field={@form[:required]} type="checkbox" label={gettext("Required")} />
<.input <.input
@ -120,16 +85,8 @@ defmodule MvWeb.CustomFieldLive.FormComponent do
@impl true @impl true
def handle_event("validate", %{"custom_field" => custom_field_params}, socket) do def handle_event("validate", %{"custom_field" => custom_field_params}, socket) do
# Remove value_type from params when editing (it's immutable after creation)
cleaned_params =
if socket.assigns[:custom_field] do
Map.delete(custom_field_params, "value_type")
else
custom_field_params
end
{:noreply, {:noreply,
assign(socket, form: AshPhoenix.Form.validate(socket.assigns.form, cleaned_params))} assign(socket, form: AshPhoenix.Form.validate(socket.assigns.form, custom_field_params))}
end end
@impl true @impl true
@ -137,15 +94,7 @@ defmodule MvWeb.CustomFieldLive.FormComponent do
# Actor must be passed from parent (IndexComponent); component socket has no current_user # Actor must be passed from parent (IndexComponent); component socket has no current_user
actor = socket.assigns[:actor] actor = socket.assigns[:actor]
# Remove value_type from params when editing (it's immutable after creation) case MvWeb.LiveHelpers.submit_form(socket.assigns.form, custom_field_params, actor) do
cleaned_params =
if socket.assigns[:custom_field] do
Map.delete(custom_field_params, "value_type")
else
custom_field_params
end
case MvWeb.LiveHelpers.submit_form(socket.assigns.form, cleaned_params, actor) do
{:ok, custom_field} -> {:ok, custom_field} ->
action = action =
case socket.assigns.form.source.type do case socket.assigns.form.source.type do

View file

@ -48,7 +48,6 @@ defmodule MvWeb.GlobalSettingsLive do
|> assign(:vereinfacht_api_url_env_set, Mv.Config.vereinfacht_api_url_env_set?()) |> assign(:vereinfacht_api_url_env_set, Mv.Config.vereinfacht_api_url_env_set?())
|> assign(:vereinfacht_api_key_env_set, Mv.Config.vereinfacht_api_key_env_set?()) |> assign(:vereinfacht_api_key_env_set, Mv.Config.vereinfacht_api_key_env_set?())
|> assign(:vereinfacht_club_id_env_set, Mv.Config.vereinfacht_club_id_env_set?()) |> assign(:vereinfacht_club_id_env_set, Mv.Config.vereinfacht_club_id_env_set?())
|> assign(:vereinfacht_app_url_env_set, Mv.Config.vereinfacht_app_url_env_set?())
|> assign(:vereinfacht_api_key_set, present?(settings.vereinfacht_api_key)) |> assign(:vereinfacht_api_key_set, present?(settings.vereinfacht_api_key))
|> assign(:last_vereinfacht_sync_result, nil) |> assign(:last_vereinfacht_sync_result, nil)
|> assign_form() |> assign_form()
@ -143,18 +142,6 @@ defmodule MvWeb.GlobalSettingsLive do
if(@vereinfacht_club_id_env_set, do: gettext("From VEREINFACHT_CLUB_ID"), else: "2") if(@vereinfacht_club_id_env_set, do: gettext("From VEREINFACHT_CLUB_ID"), else: "2")
} }
/> />
<.input
field={@form[:vereinfacht_app_url]}
type="text"
label={gettext("App URL (contact view link)")}
disabled={@vereinfacht_app_url_env_set}
placeholder={
if(@vereinfacht_app_url_env_set,
do: gettext("From VEREINFACHT_APP_URL"),
else: "https://app.verein.visuel.dev"
)
}
/>
</div> </div>
<.button <.button
:if={ :if={

View file

@ -17,12 +17,10 @@ defmodule MvWeb.GroupLive.Show do
require Logger require Logger
import Ash.Expr
import MvWeb.LiveHelpers, only: [current_actor: 1] import MvWeb.LiveHelpers, only: [current_actor: 1]
import MvWeb.Authorization import MvWeb.Authorization
alias Mv.Membership alias Mv.Membership
alias MvWeb.Helpers.MemberHelpers, as: MemberHelpers
@impl true @impl true
def mount(_params, _session, socket) do def mount(_params, _session, socket) do
@ -31,7 +29,6 @@ defmodule MvWeb.GroupLive.Show do
|> assign(:show_add_member_input, false) |> assign(:show_add_member_input, false)
|> assign(:member_search_query, "") |> assign(:member_search_query, "")
|> assign(:available_members, []) |> assign(:available_members, [])
|> assign(:add_member_candidates, [])
|> assign(:selected_member_ids, []) |> assign(:selected_member_ids, [])
|> assign(:selected_members, []) |> assign(:selected_members, [])
|> assign(:show_member_dropdown, false) |> assign(:show_member_dropdown, false)
@ -97,21 +94,13 @@ defmodule MvWeb.GroupLive.Show do
</h1> </h1>
<div class="flex gap-2"> <div class="flex gap-2">
<%= if can?(@current_user, :update, @group) do %> <%= if can?(@current_user, :update, Mv.Membership.Group) do %>
<.button <.button variant="primary" navigate={~p"/groups/#{@group.slug}/edit"}>
variant="primary"
navigate={~p"/groups/#{@group.slug}/edit"}
data-testid="group-show-edit-btn"
>
{gettext("Edit")} {gettext("Edit")}
</.button> </.button>
<% end %> <% end %>
<%= if can?(@current_user, :destroy, @group) do %> <%= if can?(@current_user, :destroy, Mv.Membership.Group) do %>
<.button <.button class="btn-error" phx-click="open_delete_modal">
class="btn-error"
phx-click="open_delete_modal"
data-testid="group-show-delete-btn"
>
{gettext("Delete")} {gettext("Delete")}
</.button> </.button>
<% end %> <% end %>
@ -134,7 +123,7 @@ defmodule MvWeb.GroupLive.Show do
<div> <div>
<h2 class="text-lg font-semibold mb-2">{gettext("Members")}</h2> <h2 class="text-lg font-semibold mb-2">{gettext("Members")}</h2>
<div class="border border-base-300 rounded-lg p-4 bg-base-100"> <div class="border border-base-300 rounded-lg p-4 bg-base-100">
<p class="mb-4" data-testid="group-show-member-count"> <p class="mb-4">
{ngettext( {ngettext(
"Total: %{count} member", "Total: %{count} member",
"Total: %{count} members", "Total: %{count} members",
@ -143,7 +132,7 @@ defmodule MvWeb.GroupLive.Show do
)} )}
</p> </p>
<%= if can?(@current_user, :update, @group) do %> <%= if can?(@current_user, :update, Mv.Membership.Group) do %>
<div class="mb-4"> <div class="mb-4">
<%= if assigns[:show_add_member_input] do %> <%= if assigns[:show_add_member_input] do %>
<div class="join w-full"> <div class="join w-full">
@ -171,7 +160,6 @@ defmodule MvWeb.GroupLive.Show do
<input <input
type="text" type="text"
id="member-search-input" id="member-search-input"
data-testid="group-show-member-search-input"
role="combobox" role="combobox"
phx-hook="ComboBox" phx-hook="ComboBox"
phx-focus="show_member_dropdown" phx-focus="show_member_dropdown"
@ -240,7 +228,6 @@ defmodule MvWeb.GroupLive.Show do
type="button" type="button"
class="btn btn-primary join-item" class="btn btn-primary join-item"
phx-click="add_selected_members" phx-click="add_selected_members"
data-testid="group-show-add-selected-members-btn"
disabled={Enum.empty?(@selected_member_ids)} disabled={Enum.empty?(@selected_member_ids)}
aria-label={gettext("Add members")} aria-label={gettext("Add members")}
> >
@ -268,17 +255,15 @@ defmodule MvWeb.GroupLive.Show do
<% end %> <% end %>
<%= if Enum.empty?(@group.members || []) do %> <%= if Enum.empty?(@group.members || []) do %>
<p class="text-base-content/50 italic" data-testid="group-show-no-members"> <p class="text-base-content/50 italic">{gettext("No members in this group")}</p>
{gettext("No members in this group")}
</p>
<% else %> <% else %>
<div class="overflow-x-auto" data-testid="group-show-members-table"> <div class="overflow-x-auto">
<table class="table table-zebra"> <table class="table table-zebra">
<thead> <thead>
<tr> <tr>
<th>{gettext("Name")}</th> <th>{gettext("Name")}</th>
<th>{gettext("Email")}</th> <th>{gettext("Email")}</th>
<%= if can?(@current_user, :update, @group) do %> <%= if can?(@current_user, :update, Mv.Membership.Group) do %>
<th class="w-0">{gettext("Actions")}</th> <th class="w-0">{gettext("Actions")}</th>
<% end %> <% end %>
</tr> </tr>
@ -306,14 +291,13 @@ defmodule MvWeb.GroupLive.Show do
<span class="text-base-content/50 italic"></span> <span class="text-base-content/50 italic"></span>
<% end %> <% end %>
</td> </td>
<%= if can?(@current_user, :update, @group) do %> <%= if can?(@current_user, :update, Mv.Membership.Group) do %>
<td> <td>
<button <button
type="button" type="button"
class="btn btn-ghost btn-sm text-error" class="btn btn-ghost btn-sm text-error"
phx-click="remove_member" phx-click="remove_member"
phx-value-member_id={member.id} phx-value-member_id={member.id}
data-testid="group-show-remove-member"
aria-label={gettext("Remove member from group")} aria-label={gettext("Remove member from group")}
data-tooltip={gettext("Remove")} data-tooltip={gettext("Remove")}
> >
@ -447,31 +431,28 @@ defmodule MvWeb.GroupLive.Show do
# Add Member Events # Add Member Events
@impl true @impl true
def handle_event("show_add_member_input", _params, socket) do def handle_event("show_add_member_input", _params, socket) do
# Load candidate members once (single DB read). Search/focus then filter in memory (R2). # Reload group to ensure we have the latest members list
socket = actor = current_actor(socket)
socket group = socket.assigns.group
|> assign(:show_add_member_input, true) socket = reload_group(socket, group.slug, actor)
|> assign(:member_search_query, "")
|> assign(:selected_member_ids, [])
|> assign(:selected_members, [])
|> assign(:show_member_dropdown, false)
|> assign(:focused_member_index, nil)
|> load_add_member_candidates()
{:noreply, socket} {:noreply,
socket
|> assign(:show_add_member_input, true)
|> assign(:member_search_query, "")
|> assign(:available_members, [])
|> assign(:selected_member_ids, [])
|> assign(:selected_members, [])
|> assign(:show_member_dropdown, false)
|> assign(:focused_member_index, nil)}
end end
@impl true @impl true
def handle_event("show_member_dropdown", _params, socket) do def handle_event("show_member_dropdown", _params, socket) do
# Filter in memory from preloaded candidates; no DB read (R2). # Use existing group.members for filtering; reload only on add/remove
query = socket.assigns.member_search_query || ""
socket = socket =
socket socket
|> assign( |> load_available_members("")
:available_members,
filter_candidates_in_memory(socket.assigns.add_member_candidates, query)
)
|> assign(:show_member_dropdown, true) |> assign(:show_member_dropdown, true)
|> assign(:focused_member_index, nil) |> assign(:focused_member_index, nil)
@ -485,7 +466,6 @@ defmodule MvWeb.GroupLive.Show do
|> assign(:show_add_member_input, false) |> assign(:show_add_member_input, false)
|> assign(:member_search_query, "") |> assign(:member_search_query, "")
|> assign(:available_members, []) |> assign(:available_members, [])
|> assign(:add_member_candidates, [])
|> assign(:selected_member_ids, []) |> assign(:selected_member_ids, [])
|> assign(:selected_members, []) |> assign(:selected_members, [])
|> assign(:show_member_dropdown, false) |> assign(:show_member_dropdown, false)
@ -552,13 +532,11 @@ defmodule MvWeb.GroupLive.Show do
@impl true @impl true
def handle_event("search_members", %{"member_search" => query}, socket) do def handle_event("search_members", %{"member_search" => query}, socket) do
# Filter in memory from preloaded candidates; no DB read (R2). # Use existing group.members for filtering; reload only on add/remove
candidates = socket.assigns.add_member_candidates || []
socket = socket =
socket socket
|> assign(:member_search_query, query) |> assign(:member_search_query, query)
|> assign(:available_members, filter_candidates_in_memory(candidates, query)) |> load_available_members(query)
|> assign(:show_member_dropdown, true) |> assign(:show_member_dropdown, true)
|> assign(:focused_member_index, nil) |> assign(:focused_member_index, nil)
@ -682,69 +660,47 @@ defmodule MvWeb.GroupLive.Show do
end end
end end
# Load candidate members once when opening add-member UI (single DB read). defp load_available_members(socket, query) do
defp load_add_member_candidates(socket) do
require Ash.Query require Ash.Query
group = socket.assigns.group current_member_ids = group_member_ids_set(socket.assigns.group)
exclude_ids = group_member_ids_set(group) |> MapSet.to_list() base_query = available_members_base_query(query)
# Fetch more than 10, then exclude already-in-group and take 10 (avoids empty dropdown when first N are all in group)
fetch_limit = 50
limited_query = Ash.Query.limit(base_query, fetch_limit)
actor = current_actor(socket) actor = current_actor(socket)
if exclude_ids == [] do case Ash.read(limited_query, actor: actor, domain: Mv.Membership) do
# No members in group; load first N members {:ok, members} ->
query = available =
Mv.Membership.Member members
|> Ash.Query.sort([:last_name, :first_name]) |> Enum.reject(fn m -> MapSet.member?(current_member_ids, m.id) end)
|> Ash.Query.limit(300) |> Enum.take(10)
do_load_add_member_candidates(socket, query, actor) assign(socket, available_members: available)
else
query =
Mv.Membership.Member
|> Ash.Query.filter(expr(id not in ^exclude_ids))
|> Ash.Query.sort([:last_name, :first_name])
|> Ash.Query.limit(300)
do_load_add_member_candidates(socket, query, actor)
end
end
defp do_load_add_member_candidates(socket, query, actor) do
case Ash.read(query, actor: actor, domain: Mv.Membership) do
{:ok, candidates} ->
socket
|> assign(:add_member_candidates, candidates)
|> assign(:available_members, Enum.take(candidates, 10))
{:error, error} -> {:error, error} ->
Logger.warning("Failed to load add-member candidates: #{inspect(error)}") Logger.warning("Failed to load available members for group: #{inspect(error)}")
socket socket
|> put_flash(:error, gettext("Could not load member list. Please try again.")) |> put_flash(:error, gettext("Could not load member search. Please try again."))
|> assign(:add_member_candidates, [])
|> assign(:available_members, []) |> assign(:available_members, [])
end end
end end
# Filter preloaded candidates by query string (name/email). No DB read. R2. defp available_members_base_query(query) do
defp filter_candidates_in_memory(candidates, query) when is_list(candidates) do search_query = if query && String.trim(query) != "", do: String.trim(query), else: nil
q = if is_binary(query), do: String.trim(query) |> String.downcase(), else: ""
if q == "" do if search_query do
candidates |> Enum.take(10) Mv.Membership.Member
|> Ash.Query.for_read(:search, %{query: search_query})
else else
candidates Mv.Membership.Member
|> Enum.filter(fn m -> |> Ash.Query.new()
name = MemberHelpers.display_name(m) |> String.downcase()
email = (m.email || "") |> String.downcase()
String.contains?(name, q) or String.contains?(email, q)
end)
|> Enum.take(10)
end end
end end
defp filter_candidates_in_memory(_, _), do: []
defp group_member_ids_set(group) do defp group_member_ids_set(group) do
members = group.members || [] members = group.members || []
members |> Enum.map(& &1.id) |> MapSet.new() members |> Enum.map(& &1.id) |> MapSet.new()
@ -784,7 +740,6 @@ defmodule MvWeb.GroupLive.Show do
|> assign(:show_add_member_input, false) |> assign(:show_add_member_input, false)
|> assign(:member_search_query, "") |> assign(:member_search_query, "")
|> assign(:available_members, []) |> assign(:available_members, [])
|> assign(:add_member_candidates, [])
|> assign(:selected_member_ids, []) |> assign(:selected_member_ids, [])
|> assign(:selected_members, []) |> assign(:selected_members, [])
|> assign(:show_member_dropdown, false) |> assign(:show_member_dropdown, false)

View file

@ -68,15 +68,18 @@ defmodule MvWeb.MemberLive.Index do
# This is appropriate for initialization errors that should be visible to the user. # This is appropriate for initialization errors that should be visible to the user.
actor = current_actor(socket) actor = current_actor(socket)
custom_fields_visible =
Mv.Membership.CustomField
|> Ash.Query.filter(expr(show_in_overview == true))
|> Ash.Query.sort(name: :asc)
|> Ash.read!(actor: actor)
# Load ALL custom fields for the dropdown (to show all available fields)
all_custom_fields = all_custom_fields =
Mv.Membership.CustomField Mv.Membership.CustomField
|> Ash.Query.sort(name: :asc) |> Ash.Query.sort(name: :asc)
|> Ash.read!(actor: actor) |> Ash.read!(actor: actor)
custom_fields_visible =
all_custom_fields
|> Enum.filter(& &1.show_in_overview)
# Load boolean custom fields (filtered and sorted from all_custom_fields) # Load boolean custom fields (filtered and sorted from all_custom_fields)
boolean_custom_fields = boolean_custom_fields =
all_custom_fields all_custom_fields
@ -160,7 +163,6 @@ defmodule MvWeb.MemberLive.Index do
- `"delete"` - Removes a member from the database - `"delete"` - Removes a member from the database
- `"select_member"` - Toggles individual member selection - `"select_member"` - Toggles individual member selection
- `"select_all"` - Toggles selection of all visible members - `"select_all"` - Toggles selection of all visible members
- `"sort"` - Sort event from SortHeaderComponent. Updates sort field/order and syncs URL
""" """
@impl true @impl true
def handle_event("delete", %{"id" => id}, socket) do def handle_event("delete", %{"id" => id}, socket) do
@ -303,46 +305,6 @@ defmodule MvWeb.MemberLive.Index do
end end
end end
@impl true
def handle_event("sort", %{"field" => field_str}, socket) do
# Handle both atom and string field names (for custom fields)
field =
try do
String.to_existing_atom(field_str)
rescue
ArgumentError -> field_str
end
{new_field, new_order} = determine_new_sort(field, socket)
old_field = socket.assigns.sort_field
socket =
socket
|> assign(:sort_field, new_field)
|> assign(:sort_order, new_order)
|> update_sort_components(old_field, new_field, new_order)
|> load_members()
|> update_selection_assigns()
# URL sync - push_patch happens synchronously in the event handler
query_params =
build_query_params(
socket.assigns.query,
export_sort_field(socket.assigns.sort_field),
export_sort_order(socket.assigns.sort_order),
socket.assigns.cycle_status_filter,
socket.assigns[:group_filters],
socket.assigns.show_current_cycle,
socket.assigns.boolean_custom_field_filters
)
|> maybe_add_field_selection(
socket.assigns[:user_field_selection],
socket.assigns[:fields_in_url?] || false
)
{:noreply, push_patch(socket, to: ~p"/members?#{query_params}", replace: true)}
end
# Helper to format errors for display # Helper to format errors for display
defp format_error(%Ash.Error.Invalid{errors: errors}) do defp format_error(%Ash.Error.Invalid{errors: errors}) do
error_messages = error_messages =
@ -367,10 +329,50 @@ defmodule MvWeb.MemberLive.Index do
Handles messages from child components. Handles messages from child components.
## Supported messages: ## Supported messages:
- `{:sort, field}` - Sort event from SortHeaderComponent. Updates sort field/order and syncs URL
- `{:search_changed, query}` - Search event from SearchBarComponent. Filters members and syncs URL - `{:search_changed, query}` - Search event from SearchBarComponent. Filters members and syncs URL
- `{:field_toggled, field, visible}` - Field toggle event from FieldVisibilityDropdownComponent - `{:field_toggled, field, visible}` - Field toggle event from FieldVisibilityDropdownComponent
- `{:fields_selected, selection}` - Select all/deselect all event from FieldVisibilityDropdownComponent - `{:fields_selected, selection}` - Select all/deselect all event from FieldVisibilityDropdownComponent
""" """
@impl true
def handle_info({:sort, field_str}, socket) do
# Handle both atom and string field names (for custom fields)
field =
try do
String.to_existing_atom(field_str)
rescue
ArgumentError -> field_str
end
{new_field, new_order} = determine_new_sort(field, socket)
old_field = socket.assigns.sort_field
socket =
socket
|> assign(:sort_field, new_field)
|> assign(:sort_order, new_order)
|> update_sort_components(old_field, new_field, new_order)
|> load_members()
|> update_selection_assigns()
# URL sync
query_params =
build_query_params(
socket.assigns.query,
export_sort_field(socket.assigns.sort_field),
export_sort_order(socket.assigns.sort_order),
socket.assigns.cycle_status_filter,
socket.assigns[:group_filters],
socket.assigns.show_current_cycle,
socket.assigns.boolean_custom_field_filters
)
|> maybe_add_field_selection(
socket.assigns[:user_field_selection],
socket.assigns[:fields_in_url?] || false
)
{:noreply, push_patch(socket, to: ~p"/members?#{query_params}", replace: true)}
end
@impl true @impl true
def handle_info({:search_changed, q}, socket) do def handle_info({:search_changed, q}, socket) do
@ -682,19 +684,6 @@ defmodule MvWeb.MemberLive.Index do
|> update_selection_assigns() |> update_selection_assigns()
end end
# Update sort components after rendering
socket =
if socket.assigns[:sort_needs_update] do
old_field = socket.assigns[:previous_sort_field] || socket.assigns.sort_field
socket
|> update_sort_components(old_field, socket.assigns.sort_field, socket.assigns.sort_order)
|> assign(:sort_needs_update, false)
|> assign(:previous_sort_field, nil)
else
socket
end
{:noreply, socket} {:noreply, socket}
end end
@ -953,10 +942,9 @@ defmodule MvWeb.MemberLive.Index do
) )
# Sort in memory if needed (custom fields, groups, group_count; computed fields are blocked) # Sort in memory if needed (custom fields, groups, group_count; computed fields are blocked)
# Note: :groups is in computed_member_fields() but can be sorted in-memory, so we only block :membership_fee_status
members = members =
if sort_after_load and if sort_after_load and
socket.assigns.sort_field != :membership_fee_status do socket.assigns.sort_field not in FieldVisibility.computed_member_fields() do
sort_members_in_memory( sort_members_in_memory(
members, members,
socket.assigns.sort_field, socket.assigns.sort_field,
@ -1058,15 +1046,21 @@ defmodule MvWeb.MemberLive.Index do
defp maybe_sort(query, _field, nil, _custom_fields), do: {query, false} defp maybe_sort(query, _field, nil, _custom_fields), do: {query, false}
defp maybe_sort(query, field, order, _custom_fields) do defp maybe_sort(query, field, order, _custom_fields) do
# :groups is in computed_member_fields() but can be sorted in-memory if computed_field?(field) do
# Only :membership_fee_status should be blocked from sorting
if field == :membership_fee_status or field == "membership_fee_status" do
{query, false} {query, false}
else else
apply_sort_to_query(query, field, order) apply_sort_to_query(query, field, order)
end end
end end
defp computed_field?(field) do
computed_atoms = FieldVisibility.computed_member_fields()
computed_strings = Enum.map(computed_atoms, &Atom.to_string/1)
(is_atom(field) and field in computed_atoms) or
(is_binary(field) and field in computed_strings)
end
defp apply_sort_to_query(query, field, order) do defp apply_sort_to_query(query, field, order) do
cond do cond do
# Groups sort -> after load (in memory) # Groups sort -> after load (in memory)
@ -1094,19 +1088,13 @@ defmodule MvWeb.MemberLive.Index do
end end
defp valid_sort_field?(field) when is_atom(field) do defp valid_sort_field?(field) when is_atom(field) do
# :groups is in computed_member_fields() but can be sorted if field in FieldVisibility.computed_member_fields(),
# Only :membership_fee_status should be blocked do: false,
if field == :membership_fee_status do else: valid_sort_field_db_or_custom?(field)
false
else
valid_sort_field_db_or_custom?(field)
end
end end
defp valid_sort_field?(field) when is_binary(field) do defp valid_sort_field?(field) when is_binary(field) do
# "groups" is in computed_member_fields() but can be sorted if field in Enum.map(FieldVisibility.computed_member_fields(), &Atom.to_string/1) do
# Only "membership_fee_status" should be blocked
if field == "membership_fee_status" do
false false
else else
valid_sort_field_db_or_custom?(field) valid_sort_field_db_or_custom?(field)
@ -1263,13 +1251,10 @@ defmodule MvWeb.MemberLive.Index do
defp maybe_update_sort(socket, %{"sort_field" => sf, "sort_order" => so}) do defp maybe_update_sort(socket, %{"sort_field" => sf, "sort_order" => so}) do
field = determine_field(socket.assigns.sort_field, sf) field = determine_field(socket.assigns.sort_field, sf)
order = determine_order(socket.assigns.sort_order, so) order = determine_order(socket.assigns.sort_order, so)
old_field = socket.assigns.sort_field
socket socket
|> assign(:sort_field, field) |> assign(:sort_field, field)
|> assign(:sort_order, order) |> assign(:sort_order, order)
|> assign(:sort_needs_update, old_field != field or socket.assigns.sort_order != order)
|> assign(:previous_sort_field, old_field)
end end
defp maybe_update_sort(socket, _), do: socket defp maybe_update_sort(socket, _), do: socket
@ -1278,27 +1263,17 @@ defmodule MvWeb.MemberLive.Index do
defp determine_field(default, nil), do: default defp determine_field(default, nil), do: default
defp determine_field(default, sf) when is_binary(sf) do defp determine_field(default, sf) when is_binary(sf) do
# Handle "groups" specially - it's in computed_member_fields() but can be sorted computed_strings = Enum.map(FieldVisibility.computed_member_fields(), &Atom.to_string/1)
if sf == "groups" do
:groups
else
computed_strings = Enum.map(FieldVisibility.computed_member_fields(), &Atom.to_string/1)
if sf in computed_strings, if sf in computed_strings,
do: default, do: default,
else: determine_field_after_computed_check(default, sf) else: determine_field_after_computed_check(default, sf)
end
end end
defp determine_field(default, sf) when is_atom(sf) do defp determine_field(default, sf) when is_atom(sf) do
# Handle :groups specially - it's in computed_member_fields() but can be sorted if sf in FieldVisibility.computed_member_fields(),
if sf == :groups do do: default,
:groups else: determine_field_after_computed_check(default, sf)
else
if sf in FieldVisibility.computed_member_fields(),
do: default,
else: determine_field_after_computed_check(default, sf)
end
end end
defp determine_field(default, _), do: default defp determine_field(default, _), do: default
@ -1647,14 +1622,6 @@ defmodule MvWeb.MemberLive.Index do
FieldVisibility.computed_member_fields() FieldVisibility.computed_member_fields()
|> Enum.filter(&(&1 in member_fields_computed)) |> Enum.filter(&(&1 in member_fields_computed))
# Include groups in export only if it's visible in the table
member_fields_with_groups =
if :groups in socket.assigns[:member_fields_visible] do
ordered_member_fields_db ++ ["groups"]
else
ordered_member_fields_db
end
# Order custom fields like the table (same as dynamic_cols / all_custom_fields order) # Order custom fields like the table (same as dynamic_cols / all_custom_fields order)
ordered_custom_field_ids = ordered_custom_field_ids =
socket.assigns.all_custom_fields socket.assigns.all_custom_fields
@ -1663,11 +1630,7 @@ defmodule MvWeb.MemberLive.Index do
%{ %{
selected_ids: socket.assigns.selected_members |> MapSet.to_list(), selected_ids: socket.assigns.selected_members |> MapSet.to_list(),
member_fields: member_fields: Enum.map(ordered_member_fields_db, &Atom.to_string/1),
Enum.map(member_fields_with_groups, fn
f when is_atom(f) -> Atom.to_string(f)
f when is_binary(f) -> f
end),
computed_fields: Enum.map(ordered_computed_fields, &Atom.to_string/1), computed_fields: Enum.map(ordered_computed_fields, &Atom.to_string/1),
custom_field_ids: ordered_custom_field_ids, custom_field_ids: ordered_custom_field_ids,
column_order: column_order:

View file

@ -331,7 +331,6 @@
</:col> </:col>
<:col <:col
:let={member} :let={member}
:if={:groups in @member_fields_visible}
label={ label={
~H""" ~H"""
<.live_component <.live_component

View file

@ -28,8 +28,7 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do
alias Mv.Membership.Helpers.VisibilityConfig alias Mv.Membership.Helpers.VisibilityConfig
# Single UI key for "Membership Fee Status"; only this appears in the dropdown. # Single UI key for "Membership Fee Status"; only this appears in the dropdown.
# Groups is also a pseudo field (not a DB attribute, but displayed in the table). @pseudo_member_fields [:membership_fee_status]
@pseudo_member_fields [:membership_fee_status, :groups]
# Export/API may accept this as alias; must not appear in the UI options list. # Export/API may accept this as alias; must not appear in the UI options list.
@export_only_alias :payment_status @export_only_alias :payment_status
@ -202,7 +201,7 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do
""" """
@spec get_visible_member_fields_computed(%{String.t() => boolean()}) :: [atom()] @spec get_visible_member_fields_computed(%{String.t() => boolean()}) :: [atom()]
def get_visible_member_fields_computed(field_selection) when is_map(field_selection) do def get_visible_member_fields_computed(field_selection) when is_map(field_selection) do
computed_set = MapSet.new([:membership_fee_status]) computed_set = MapSet.new(@pseudo_member_fields)
field_selection field_selection
|> Enum.filter(fn {field_string, visible} -> |> Enum.filter(fn {field_string, visible} ->

View file

@ -256,7 +256,7 @@ defmodule MvWeb.MemberLive.Show do
id={"membership-fees-#{@member.id}"} id={"membership-fees-#{@member.id}"}
member={@member} member={@member}
current_user={@current_user} current_user={@current_user}
vereinfacht_receipts={@vereinfacht_receipts} vereinfacht_debug_response={@vereinfacht_debug_response}
/> />
<% end %> <% end %>
</Layouts.app> </Layouts.app>
@ -268,7 +268,7 @@ defmodule MvWeb.MemberLive.Show do
{:ok, {:ok,
socket socket
|> assign(:active_tab, :contact) |> assign(:active_tab, :contact)
|> assign(:vereinfacht_receipts, nil)} |> assign(:vereinfacht_debug_response, nil)}
end end
@impl true @impl true
@ -320,14 +320,14 @@ defmodule MvWeb.MemberLive.Show do
{:noreply, assign(socket, :active_tab, :membership_fees)} {:noreply, assign(socket, :active_tab, :membership_fees)}
end end
def handle_event("load_vereinfacht_receipts", %{"contact_id" => contact_id}, socket) do def handle_event("load_vereinfacht_debug", %{"contact_id" => contact_id}, socket) do
response = response =
case Mv.Vereinfacht.Client.get_contact_with_receipts(contact_id) do case Mv.Vereinfacht.Client.get_contact(contact_id) do
{:ok, receipts} -> {:ok, receipts} {:ok, body} -> {:ok, body}
{:error, reason} -> {:error, reason} {:error, reason} -> {:error, reason}
end end
{:noreply, assign(socket, :vereinfacht_receipts, response)} {:noreply, assign(socket, :vereinfacht_debug_response, response)}
end end
# Flash set in LiveComponent is not shown in parent layout; child sends this to display flash # Flash set in LiveComponent is not shown in parent layout; child sends this to display flash

View file

@ -54,67 +54,42 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
<%= if Mv.Config.vereinfacht_configured?() do %> <%= if Mv.Config.vereinfacht_configured?() do %>
<%= if @member.vereinfacht_contact_id do %> <%= if @member.vereinfacht_contact_id do %>
<div class="mb-4"> <div class="mb-4">
<div class="flex flex-col gap-2"> <label class="label">
<span class="label-text font-semibold">{gettext("Vereinfacht")}</span>
</label>
<div class="flex flex-col gap-1">
<span class="font-mono text-sm">
{gettext("Contact ID: %{id}", id: @member.vereinfacht_contact_id)}
</span>
<.link <.link
:if={Mv.Config.vereinfacht_contact_view_url(@member.vereinfacht_contact_id)} :if={Mv.Config.vereinfacht_contact_view_url(@member.vereinfacht_contact_id)}
href={Mv.Config.vereinfacht_contact_view_url(@member.vereinfacht_contact_id)} href={Mv.Config.vereinfacht_contact_view_url(@member.vereinfacht_contact_id)}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
class="link link-accent underline inline-flex items-center gap-1 w-fit" class="link link-accent underline inline-flex items-center gap-1"
> >
{gettext("View contact in Vereinfacht")} {gettext("View contact in Vereinfacht")}
<.icon name="hero-arrow-top-right-on-square" class="inline-block size-4" /> <.icon name="hero-arrow-top-right-on-square" class="inline-block size-4" />
</.link> </.link>
<div> <div class="mt-2">
<span class="text-base-content/70 text-sm">{gettext("Debug:")}</span>
<button <button
type="button" type="button"
phx-click="load_vereinfacht_receipts" phx-click="load_vereinfacht_debug"
phx-value-contact_id={@member.vereinfacht_contact_id} phx-value-contact_id={@member.vereinfacht_contact_id}
class="btn btn-sm btn-ghost" class="btn btn-sm btn-ghost ml-1"
> >
{gettext("Show bookings/receipts from Vereinfacht")} {gettext("Load API response")}
</button> </button>
</div> </div>
<%= if @vereinfacht_receipts do %> <%= if @vereinfacht_debug_response do %>
<div <div
class="mt-2 rounded border border-base-300 bg-base-200 p-3 overflow-x-auto max-h-96 overflow-y-auto" class="mt-2 rounded border border-base-300 bg-base-200 p-3 overflow-x-auto max-h-96 overflow-y-auto"
tabindex="0" tabindex="0"
role="region" role="region"
aria-label={gettext("Vereinfacht receipts")} aria-label={gettext("Vereinfacht API response")}
> >
<%= if match?({:ok, _}, @vereinfacht_receipts) do %> <pre class="text-xs whitespace-pre-wrap font-mono"><%= format_vereinfacht_debug_response(@vereinfacht_debug_response) %></pre>
<% {_, receipts} = @vereinfacht_receipts %>
<%= if receipts == [] do %>
<p class="text-sm text-base-content/70">{gettext("No receipts")}</p>
<% else %>
<% cols = receipt_display_columns(receipts) %>
<table class="table table-xs table-pin-rows">
<thead>
<tr>
<%= for {_key, translated_label} <- cols do %>
<th>{translated_label}</th>
<% end %>
</tr>
</thead>
<tbody>
<%= for r <- receipts do %>
<tr>
<%= for {col_key, _header_key} <- cols do %>
<td>{format_receipt_cell(col_key, r[col_key])}</td>
<% end %>
</tr>
<% end %>
</tbody>
</table>
<% end %>
<% else %>
<% {:error, reason} = @vereinfacht_receipts %>
<p class="text-sm text-error">
{gettext("Error loading receipts: %{reason}",
reason: format_vereinfacht_error(reason)
)}
</p>
<% end %>
</div> </div>
<% end %> <% end %>
</div> </div>
@ -524,7 +499,7 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
|> assign_new(:create_cycle_date, fn -> nil end) |> assign_new(:create_cycle_date, fn -> nil end)
|> assign_new(:create_cycle_error, fn -> nil end) |> assign_new(:create_cycle_error, fn -> nil end)
|> assign_new(:regenerating, fn -> false end) |> assign_new(:regenerating, fn -> false end)
|> assign_new(:vereinfacht_receipts, fn -> nil end)} |> assign_new(:vereinfacht_debug_response, fn -> nil end)}
end end
@impl true @impl true
@ -1082,138 +1057,23 @@ defmodule MvWeb.MemberLive.Show.MembershipFeesComponent do
defp format_create_cycle_period(_date, _interval), do: "" defp format_create_cycle_period(_date, _interval), do: ""
defp format_vereinfacht_error({:http, status, detail}) when is_binary(detail), defp format_vereinfacht_debug_response({:ok, body}) when is_map(body) do
do: "HTTP #{status} #{detail}" Jason.encode!(body, pretty: true)
defp format_vereinfacht_error({:http, status, _}), do: "HTTP #{status}"
defp format_vereinfacht_error(reason), do: inspect(reason)
# Ordered receipt columns: {api_key, gettext key for header}. Only columns present in data are shown.
@receipt_column_spec [
{:amount, "Amount"},
{:bookingDate, "Booking date"},
{:createdAt, "Created at"},
{:receiptType, "Receipt type"},
{:referenceNumber, "Reference number"},
{:status, "Status"},
{:updatedAt, "Updated at"}
]
defp receipt_display_columns(receipts) when is_list(receipts) do
keys_in_data = receipts |> Enum.flat_map(&Map.keys/1) |> MapSet.new()
Enum.filter(@receipt_column_spec, fn {key, _} -> MapSet.member?(keys_in_data, key) end)
|> Enum.map(fn {key, msgid} -> {key, Gettext.gettext(MvWeb.Gettext, msgid)} end)
end end
defp format_receipt_cell(:amount, nil), do: "" defp format_vereinfacht_debug_response({:error, {:http, status, detail}})
when is_binary(detail) do
defp format_receipt_cell(:amount, val) when is_number(val) do "Error: HTTP #{status} #{detail}"
case Decimal.cast(val) do
{:ok, d} -> MembershipFeeHelpers.format_currency(d)
_ -> to_string(val)
end
end end
defp format_receipt_cell(:amount, val) when is_binary(val) do defp format_vereinfacht_debug_response({:error, {:http, status, _}}) do
case Decimal.parse(val) do "Error: HTTP #{status}"
{d, _} -> MembershipFeeHelpers.format_currency(d)
:error -> val
end
end end
defp format_receipt_cell(:amount, val), do: to_string(val) defp format_vereinfacht_debug_response({:error, reason}) do
"Error: " <> inspect(reason)
defp format_receipt_cell(:status, nil), do: ""
defp format_receipt_cell(:status, val) when is_binary(val) do
translate_receipt_status(val)
end end
defp format_receipt_cell(:status, val), do: translate_receipt_status(to_string(val))
defp format_receipt_cell(:receiptType, nil), do: ""
defp format_receipt_cell(:receiptType, val) when is_binary(val) do
translate_receipt_type(val)
end
defp format_receipt_cell(:receiptType, val), do: translate_receipt_type(to_string(val))
defp format_receipt_cell(col_key, nil) when col_key in [:bookingDate, :createdAt, :updatedAt],
do: ""
defp format_receipt_cell(col_key, val) when col_key in [:bookingDate, :createdAt, :updatedAt] do
format_receipt_date(val)
end
defp format_receipt_cell(_col_key, val) when is_binary(val), do: val
defp format_receipt_cell(_col_key, val) when is_number(val), do: to_string(val)
defp format_receipt_cell(_col_key, val) when is_boolean(val),
do: if(val, do: gettext("Yes"), else: gettext("No"))
defp format_receipt_cell(_col_key, %Date{} = d), do: format_receipt_date_short(d)
defp format_receipt_cell(_col_key, val) when is_map(val) or is_list(val), do: Jason.encode!(val)
defp format_receipt_cell(_col_key, val), do: to_string(val)
defp format_receipt_date(%Date{} = d), do: format_receipt_date_short(d)
defp format_receipt_date(val) when is_binary(val) do
case parse_receipt_date(val) do
{:ok, d} -> format_receipt_date_short(d)
_ -> val
end
end
defp format_receipt_date(val), do: to_string(val)
# Parses ISO date or datetime string to Date (uses first 10 chars for datetime strings)
defp parse_receipt_date(val) when is_binary(val) do
date_str = if String.length(val) >= 10, do: String.slice(val, 0, 10), else: val
Date.from_iso8601(date_str)
end
# Format as "12. Dez. 2025" (day. abbreviated month. year) with translated month
defp format_receipt_date_short(%Date{day: day, month: month, year: year}) do
"#{day}. #{receipt_month_abbr(month)} #{year}"
end
defp receipt_month_abbr(1), do: gettext("Jan.")
defp receipt_month_abbr(2), do: gettext("Feb.")
defp receipt_month_abbr(3), do: gettext("Mar.")
defp receipt_month_abbr(4), do: gettext("Apr.")
defp receipt_month_abbr(5), do: gettext("May")
defp receipt_month_abbr(6), do: gettext("Jun.")
defp receipt_month_abbr(7), do: gettext("Jul.")
defp receipt_month_abbr(8), do: gettext("Aug.")
defp receipt_month_abbr(9), do: gettext("Sep.")
defp receipt_month_abbr(10), do: gettext("Oct.")
defp receipt_month_abbr(11), do: gettext("Nov.")
defp receipt_month_abbr(12), do: gettext("Dec.")
defp receipt_month_abbr(_), do: ""
# Translate API status values for display (extend as API returns more values)
defp translate_receipt_status("paid"), do: gettext("Paid")
defp translate_receipt_status("unpaid"), do: gettext("Unpaid")
defp translate_receipt_status("suspended"), do: gettext("Suspended")
defp translate_receipt_status("open"), do: gettext("Open")
defp translate_receipt_status("cancelled"), do: gettext("Cancelled")
defp translate_receipt_status("draft"), do: gettext("Draft")
defp translate_receipt_status("incompleted"), do: gettext("Incompleted")
defp translate_receipt_status("completed"), do: gettext("Completed")
defp translate_receipt_status("empty"), do: ""
defp translate_receipt_status(other), do: other
# Translate API receipt type values (extend as API returns more values)
defp translate_receipt_type("invoice"), do: gettext("Invoice")
defp translate_receipt_type("receipt"), do: gettext("Receipt")
defp translate_receipt_type("credit_note"), do: gettext("Credit note")
defp translate_receipt_type("credit"), do: gettext("Credit")
defp translate_receipt_type("expense"), do: gettext("Expense")
defp translate_receipt_type("income"), do: gettext("Income")
defp translate_receipt_type(other), do: other
# Helper component for section box # Helper component for section box
attr :title, :string, required: true attr :title, :string, required: true
slot :inner_block, required: true slot :inner_block, required: true

View file

@ -29,7 +29,6 @@ defmodule MvWeb.Translations.MemberFields do
def label(:postal_code), do: gettext("Postal Code") def label(:postal_code), do: gettext("Postal Code")
def label(:membership_fee_start_date), do: gettext("Membership Fee Start Date") def label(:membership_fee_start_date), do: gettext("Membership Fee Start Date")
def label(:membership_fee_status), do: gettext("Membership Fee Status") def label(:membership_fee_status), do: gettext("Membership Fee Status")
def label(:groups), do: gettext("Groups")
# Fallback for unknown fields # Fallback for unknown fields
def label(field) do def label(field) do

View file

@ -27,7 +27,6 @@ msgid "Are you sure?"
msgstr "Bist du sicher?" msgstr "Bist du sicher?"
#: lib/mv_web/components/layouts.ex #: lib/mv_web/components/layouts.ex
#: lib/mv_web/components/layouts/root.html.heex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Attempting to reconnect" msgid "Attempting to reconnect"
msgstr "Verbindung wird wiederhergestellt" msgstr "Verbindung wird wiederhergestellt"
@ -116,13 +115,11 @@ msgid "Show"
msgstr "Anzeigen" msgstr "Anzeigen"
#: lib/mv_web/components/layouts.ex #: lib/mv_web/components/layouts.ex
#: lib/mv_web/components/layouts/root.html.heex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Something went wrong!" msgid "Something went wrong!"
msgstr "Etwas ist schiefgelaufen!" msgstr "Etwas ist schiefgelaufen!"
#: lib/mv_web/components/layouts.ex #: lib/mv_web/components/layouts.ex
#: lib/mv_web/components/layouts/root.html.heex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "We can't find the internet" msgid "We can't find the internet"
msgstr "Keine Internetverbindung gefunden" msgstr "Keine Internetverbindung gefunden"
@ -200,7 +197,6 @@ msgstr "Straße"
#: lib/mv_web/live/member_field_live/index_component.ex #: lib/mv_web/live/member_field_live/index_component.ex
#: lib/mv_web/live/member_live/index/formatter.ex #: lib/mv_web/live/member_live/index/formatter.ex
#: lib/mv_web/live/member_live/show.ex #: lib/mv_web/live/member_live/show.ex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#: lib/mv_web/live/role_live/show.ex #: lib/mv_web/live/role_live/show.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "No" msgid "No"
@ -586,16 +582,6 @@ msgstr "Ein Konto mit dieser E-Mail existiert bereits. Bitte verifiziere dein Pa
msgid "Unable to authenticate with OIDC. Please try again." msgid "Unable to authenticate with OIDC. Please try again."
msgstr "OIDC-Authentifizierung fehlgeschlagen. Bitte versuche es erneut." msgstr "OIDC-Authentifizierung fehlgeschlagen. Bitte versuche es erneut."
#: lib/mv_web/controllers/auth_controller.ex
#, elixir-autogen, elixir-format
msgid "The authentication server is currently unavailable. Please try again later."
msgstr "Der Authentifizierungsserver ist derzeit nicht erreichbar. Bitte versuche es später erneut."
#: lib/mv_web/controllers/auth_controller.ex
#, elixir-autogen, elixir-format
msgid "Authentication configuration error. Please contact the administrator."
msgstr "Authentifizierungskonfigurationsfehler. Bitte kontaktiere den Administrator."
#: lib/mv_web/controllers/auth_controller.ex #: lib/mv_web/controllers/auth_controller.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Unable to sign in. Please try again." msgid "Unable to sign in. Please try again."
@ -2219,7 +2205,6 @@ msgstr "Gruppe erfolgreich gespeichert."
#: lib/mv_web/live/group_live/index.ex #: lib/mv_web/live/group_live/index.ex
#: lib/mv_web/live/member_live/index.html.heex #: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/member_live/show.ex #: lib/mv_web/live/member_live/show.ex
#: lib/mv_web/translations/member_fields.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Groups" msgid "Groups"
msgstr "Gruppen" msgstr "Gruppen"
@ -2279,6 +2264,11 @@ msgstr "Nicht berechtigt."
msgid "Could not load data fields. Please check your permissions." msgid "Could not load data fields. Please check your permissions."
msgstr "Datenfelder konnten nicht geladen werden. Bitte überprüfe deine Berechtigungen." msgstr "Datenfelder konnten nicht geladen werden. Bitte überprüfe deine Berechtigungen."
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format
msgid "Could not load member search. Please try again."
msgstr "Mitgliedersuche konnte nicht geladen werden. Bitte versuchen Sie es erneut."
#: lib/mv_web/live/group_live/show.ex #: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Add Member" msgid "Add Member"
@ -2614,16 +2604,6 @@ msgstr "PDF"
msgid "Import" msgid "Import"
msgstr "Import" msgstr "Import"
#: lib/mv_web/live/custom_field_live/form_component.ex
#, elixir-autogen, elixir-format
msgid "Value type cannot be changed after creation"
msgstr "Der Wertetyp kann nach dem Erstellen nicht mehr geändert werden."
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Could not load member list. Please try again."
msgstr "Mitgliederliste konnte nicht geladen werden. Bitte versuche es erneut."
#: lib/mv_web/live/global_settings_live.ex #: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "API Key" msgid "API Key"
@ -2639,6 +2619,11 @@ msgstr "API-URL"
msgid "Club ID" msgid "Club ID"
msgstr "Vereins-ID" msgstr "Vereins-ID"
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Contact ID: %{id}"
msgstr "Kontakt-ID: %{id}"
#: lib/mv_web/live/global_settings_live.ex #: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "From VEREINFACHT_API_KEY" msgid "From VEREINFACHT_API_KEY"
@ -2674,6 +2659,11 @@ msgstr "%{count} Mitglied(er) mit Vereinfacht synchronisiert."
msgid "Syncing..." msgid "Syncing..."
msgstr "Synchronisiere..." msgstr "Synchronisiere..."
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Vereinfacht"
msgstr "Vereinfacht"
#: lib/mv_web/live/global_settings_live.ex #: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Vereinfacht Integration" msgid "Vereinfacht Integration"
@ -2689,6 +2679,16 @@ msgstr "Vereinfacht ist nicht konfiguriert. Bitte API-URL, API-Schlüssel und Ve
msgid "View contact in Vereinfacht" msgid "View contact in Vereinfacht"
msgstr "Kontakt in Vereinfacht anzeigen" msgstr "Kontakt in Vereinfacht anzeigen"
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Debug:"
msgstr "Debug:"
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Load API response"
msgstr "API-Antwort laden"
#: lib/mv_web/live/global_settings_live.ex #: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "%{count} failed" msgid "%{count} failed"
@ -2730,6 +2730,11 @@ msgstr "Für dieses Mitglied existiert kein Vereinfacht-Kontakt."
msgid "Sync this member from Settings (Vereinfacht section) or save the member again to create the contact." msgid "Sync this member from Settings (Vereinfacht section) or save the member again to create the contact."
msgstr "Synchronisieren Sie dieses Mitglied unter Einstellungen (Bereich Vereinfacht) oder speichern Sie das Mitglied erneut, um den Kontakt anzulegen." msgstr "Synchronisieren Sie dieses Mitglied unter Einstellungen (Bereich Vereinfacht) oder speichern Sie das Mitglied erneut, um den Kontakt anzulegen."
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Vereinfacht API response"
msgstr "Vereinfacht"
#: lib/mv_web/live/global_settings_live.ex #: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "(set)" msgid "(set)"
@ -2766,148 +2771,3 @@ msgstr "Das Postleitzahlenfeld ist erforderlich."
msgid "Too Many Attempts." msgid "Too Many Attempts."
msgstr "Zu viele Versuche." msgstr "Zu viele Versuche."
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "App URL (contact view link)"
msgstr "App-URL (Link zur Kontaktansicht)"
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "From VEREINFACHT_APP_URL"
msgstr "Aus VEREINFACHT_APP_URL"
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Error loading receipts: %{reason}"
msgstr "Belege konnten nicht geladen werden: %{reason}"
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "No receipts"
msgstr "Keine Belege"
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Show bookings/receipts from Vereinfacht"
msgstr "Buchungen/Belege aus Vereinfacht anzeigen"
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Vereinfacht receipts"
msgstr "Vereinfacht-Belege"
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Cancelled"
msgstr "Storniert"
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Credit"
msgstr "Gutschrift"
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Credit note"
msgstr "Gutschrift"
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Draft"
msgstr "Entwurf"
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Invoice"
msgstr "Rechnung"
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Open"
msgstr "Offen"
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Receipt"
msgstr "Beleg"
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Apr."
msgstr "Apr."
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Aug."
msgstr "Aug."
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Completed"
msgstr "Abgeschlossen"
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Dec."
msgstr "Dez."
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Expense"
msgstr "Ausgabe"
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Feb."
msgstr "Feb."
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Income"
msgstr "Einnahme"
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Incompleted"
msgstr "Unvollständig"
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Jan."
msgstr "Jan."
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Jul."
msgstr "Jul."
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Jun."
msgstr "Jun."
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Mar."
msgstr "Mär."
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "May"
msgstr "Mai"
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Nov."
msgstr "Nov."
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Oct."
msgstr "Okt."
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Sep."
msgstr "Sep."

View file

@ -28,7 +28,6 @@ msgid "Are you sure?"
msgstr "" msgstr ""
#: lib/mv_web/components/layouts.ex #: lib/mv_web/components/layouts.ex
#: lib/mv_web/components/layouts/root.html.heex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Attempting to reconnect" msgid "Attempting to reconnect"
msgstr "" msgstr ""
@ -117,13 +116,11 @@ msgid "Show"
msgstr "" msgstr ""
#: lib/mv_web/components/layouts.ex #: lib/mv_web/components/layouts.ex
#: lib/mv_web/components/layouts/root.html.heex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Something went wrong!" msgid "Something went wrong!"
msgstr "" msgstr ""
#: lib/mv_web/components/layouts.ex #: lib/mv_web/components/layouts.ex
#: lib/mv_web/components/layouts/root.html.heex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "We can't find the internet" msgid "We can't find the internet"
msgstr "" msgstr ""
@ -201,7 +198,6 @@ msgstr ""
#: lib/mv_web/live/member_field_live/index_component.ex #: lib/mv_web/live/member_field_live/index_component.ex
#: lib/mv_web/live/member_live/index/formatter.ex #: lib/mv_web/live/member_live/index/formatter.ex
#: lib/mv_web/live/member_live/show.ex #: lib/mv_web/live/member_live/show.ex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#: lib/mv_web/live/role_live/show.ex #: lib/mv_web/live/role_live/show.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "No" msgid "No"
@ -587,16 +583,6 @@ msgstr ""
msgid "Unable to authenticate with OIDC. Please try again." msgid "Unable to authenticate with OIDC. Please try again."
msgstr "" msgstr ""
#: lib/mv_web/controllers/auth_controller.ex
#, elixir-autogen, elixir-format
msgid "The authentication server is currently unavailable. Please try again later."
msgstr ""
#: lib/mv_web/controllers/auth_controller.ex
#, elixir-autogen, elixir-format
msgid "Authentication configuration error. Please contact the administrator."
msgstr ""
#: lib/mv_web/controllers/auth_controller.ex #: lib/mv_web/controllers/auth_controller.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Unable to sign in. Please try again." msgid "Unable to sign in. Please try again."
@ -2220,7 +2206,6 @@ msgstr ""
#: lib/mv_web/live/group_live/index.ex #: lib/mv_web/live/group_live/index.ex
#: lib/mv_web/live/member_live/index.html.heex #: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/member_live/show.ex #: lib/mv_web/live/member_live/show.ex
#: lib/mv_web/translations/member_fields.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Groups" msgid "Groups"
msgstr "" msgstr ""
@ -2280,6 +2265,11 @@ msgstr ""
msgid "Could not load data fields. Please check your permissions." msgid "Could not load data fields. Please check your permissions."
msgstr "" msgstr ""
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format
msgid "Could not load member search. Please try again."
msgstr ""
#: lib/mv_web/live/group_live/show.ex #: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Add Member" msgid "Add Member"
@ -2615,16 +2605,6 @@ msgstr ""
msgid "Import" msgid "Import"
msgstr "" msgstr ""
#: lib/mv_web/live/custom_field_live/form_component.ex
#, elixir-autogen, elixir-format
msgid "Value type cannot be changed after creation"
msgstr ""
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format
msgid "Could not load member list. Please try again."
msgstr ""
#: lib/mv_web/live/global_settings_live.ex #: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "API Key" msgid "API Key"
@ -2640,6 +2620,11 @@ msgstr ""
msgid "Club ID" msgid "Club ID"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Contact ID: %{id}"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex #: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "From VEREINFACHT_API_KEY" msgid "From VEREINFACHT_API_KEY"
@ -2675,6 +2660,11 @@ msgstr ""
msgid "Syncing..." msgid "Syncing..."
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Vereinfacht"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex #: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Vereinfacht Integration" msgid "Vereinfacht Integration"
@ -2690,6 +2680,16 @@ msgstr ""
msgid "View contact in Vereinfacht" msgid "View contact in Vereinfacht"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Debug:"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Load API response"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex #: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "%{count} failed" msgid "%{count} failed"
@ -2730,6 +2730,11 @@ msgstr ""
msgid "Sync this member from Settings (Vereinfacht section) or save the member again to create the contact." msgid "Sync this member from Settings (Vereinfacht section) or save the member again to create the contact."
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Vereinfacht API response"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex #: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "(set)" msgid "(set)"
@ -2766,148 +2771,3 @@ msgstr ""
msgid "Too Many Attempts." msgid "Too Many Attempts."
msgstr "" msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "App URL (contact view link)"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "From VEREINFACHT_APP_URL"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Error loading receipts: %{reason}"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "No receipts"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Show bookings/receipts from Vereinfacht"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Vereinfacht receipts"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Cancelled"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Credit"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Credit note"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Draft"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Invoice"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Open"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Receipt"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Apr."
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Aug."
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Completed"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Dec."
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Expense"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Feb."
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Income"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Incompleted"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Jan."
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Jul."
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Jun."
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Mar."
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "May"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Nov."
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Oct."
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Sep."
msgstr ""

View file

@ -28,7 +28,6 @@ msgid "Are you sure?"
msgstr "" msgstr ""
#: lib/mv_web/components/layouts.ex #: lib/mv_web/components/layouts.ex
#: lib/mv_web/components/layouts/root.html.heex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Attempting to reconnect" msgid "Attempting to reconnect"
msgstr "" msgstr ""
@ -117,13 +116,11 @@ msgid "Show"
msgstr "" msgstr ""
#: lib/mv_web/components/layouts.ex #: lib/mv_web/components/layouts.ex
#: lib/mv_web/components/layouts/root.html.heex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Something went wrong!" msgid "Something went wrong!"
msgstr "" msgstr ""
#: lib/mv_web/components/layouts.ex #: lib/mv_web/components/layouts.ex
#: lib/mv_web/components/layouts/root.html.heex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "We can't find the internet" msgid "We can't find the internet"
msgstr "" msgstr ""
@ -201,7 +198,6 @@ msgstr ""
#: lib/mv_web/live/member_field_live/index_component.ex #: lib/mv_web/live/member_field_live/index_component.ex
#: lib/mv_web/live/member_live/index/formatter.ex #: lib/mv_web/live/member_live/index/formatter.ex
#: lib/mv_web/live/member_live/show.ex #: lib/mv_web/live/member_live/show.ex
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#: lib/mv_web/live/role_live/show.ex #: lib/mv_web/live/role_live/show.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "No" msgid "No"
@ -587,16 +583,6 @@ msgstr ""
msgid "Unable to authenticate with OIDC. Please try again." msgid "Unable to authenticate with OIDC. Please try again."
msgstr "" msgstr ""
#: lib/mv_web/controllers/auth_controller.ex
#, elixir-autogen, elixir-format
msgid "The authentication server is currently unavailable. Please try again later."
msgstr ""
#: lib/mv_web/controllers/auth_controller.ex
#, elixir-autogen, elixir-format
msgid "Authentication configuration error. Please contact the administrator."
msgstr ""
#: lib/mv_web/controllers/auth_controller.ex #: lib/mv_web/controllers/auth_controller.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Unable to sign in. Please try again." msgid "Unable to sign in. Please try again."
@ -2220,7 +2206,6 @@ msgstr ""
#: lib/mv_web/live/group_live/index.ex #: lib/mv_web/live/group_live/index.ex
#: lib/mv_web/live/member_live/index.html.heex #: lib/mv_web/live/member_live/index.html.heex
#: lib/mv_web/live/member_live/show.ex #: lib/mv_web/live/member_live/show.ex
#: lib/mv_web/translations/member_fields.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Groups" msgid "Groups"
msgstr "" msgstr ""
@ -2280,6 +2265,11 @@ msgstr ""
msgid "Could not load data fields. Please check your permissions." msgid "Could not load data fields. Please check your permissions."
msgstr "" msgstr ""
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format
msgid "Could not load member search. Please try again."
msgstr ""
#: lib/mv_web/live/group_live/show.ex #: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format, fuzzy #, elixir-autogen, elixir-format, fuzzy
msgid "Add Member" msgid "Add Member"
@ -2615,16 +2605,6 @@ msgstr ""
msgid "Import" msgid "Import"
msgstr "" msgstr ""
#: lib/mv_web/live/custom_field_live/form_component.ex
#, elixir-autogen, elixir-format
msgid "Value type cannot be changed after creation"
msgstr ""
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Could not load member list. Please try again."
msgstr ""
#: lib/mv_web/live/global_settings_live.ex #: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "API Key" msgid "API Key"
@ -2640,6 +2620,11 @@ msgstr ""
msgid "Club ID" msgid "Club ID"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Contact ID: %{id}"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex #: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "From VEREINFACHT_API_KEY" msgid "From VEREINFACHT_API_KEY"
@ -2675,6 +2660,11 @@ msgstr ""
msgid "Syncing..." msgid "Syncing..."
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Vereinfacht"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex #: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Vereinfacht Integration" msgid "Vereinfacht Integration"
@ -2690,6 +2680,16 @@ msgstr ""
msgid "View contact in Vereinfacht" msgid "View contact in Vereinfacht"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Debug:"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Load API response"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex #: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "%{count} failed" msgid "%{count} failed"
@ -2730,6 +2730,11 @@ msgstr ""
msgid "Sync this member from Settings (Vereinfacht section) or save the member again to create the contact." msgid "Sync this member from Settings (Vereinfacht section) or save the member again to create the contact."
msgstr "Sync this member from Settings (Vereinfacht section) or save the member again to create the contact." msgstr "Sync this member from Settings (Vereinfacht section) or save the member again to create the contact."
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Vereinfacht API response"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex #: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "(set)" msgid "(set)"
@ -2766,148 +2771,3 @@ msgstr ""
msgid "Too Many Attempts." msgid "Too Many Attempts."
msgstr "" msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
msgid "App URL (contact view link)"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "From VEREINFACHT_APP_URL"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Error loading receipts: %{reason}"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "No receipts"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Show bookings/receipts from Vereinfacht"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Vereinfacht receipts"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Cancelled"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Credit"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Credit note"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Draft"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Invoice"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Open"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Receipt"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Apr."
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Aug."
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Completed"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Dec."
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Expense"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Feb."
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Income"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Incompleted"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Jan."
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Jul."
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Jun."
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Mar."
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "May"
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Nov."
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Oct."
msgstr ""
#: lib/mv_web/live/member_live/show/membership_fees_component.ex
#, elixir-autogen, elixir-format
msgid "Sep."
msgstr ""

View file

@ -1,15 +0,0 @@
defmodule Mv.Repo.Migrations.AddVereinfachtAppUrl do
use Ecto.Migration
def up do
alter table(:settings) do
add :vereinfacht_app_url, :text
end
end
def down do
alter table(:settings) do
remove :vereinfacht_app_url
end
end
end

View file

@ -3,10 +3,10 @@
# mix run priv/repo/seeds.exs # mix run priv/repo/seeds.exs
# #
alias Mv.Accounts
alias Mv.Membership alias Mv.Membership
alias Mv.MembershipFees.CycleGenerator alias Mv.Accounts
alias Mv.MembershipFees.MembershipFeeType alias Mv.MembershipFees.MembershipFeeType
alias Mv.MembershipFees.CycleGenerator
require Ash.Query require Ash.Query
@ -579,39 +579,6 @@ Enum.with_index(linked_members)
end end
end) end)
# Create example groups (idempotent: create only if name does not exist)
group_configs = [
%{name: "Vorstand", description: "Gremium Vorstand"},
%{name: "Trainer*innen", description: "Alle lizenzierten Trainer*innen"},
%{name: "Jugend", description: "Jugendbereich"},
%{name: "Newsletter", description: "Empfänger*innen Newsletter"}
]
existing_groups =
case Membership.list_groups(actor: admin_user_with_role) do
{:ok, list} -> list
{:error, _} -> []
end
existing_names_lower = MapSet.new(existing_groups, &String.downcase(&1.name))
seed_groups =
Enum.reduce(group_configs, %{}, fn config, acc ->
name = config.name
if MapSet.member?(existing_names_lower, String.downcase(name)) do
group = Enum.find(existing_groups, &(String.downcase(&1.name) == String.downcase(name)))
Map.put(acc, name, group)
else
group =
Membership.create_group!(%{name: name, description: config.description},
actor: admin_user_with_role
)
Map.put(acc, name, group)
end
end)
# Create sample custom field values for some members # Create sample custom field values for some members
all_members = Ash.read!(Membership.Member, actor: admin_user_with_role) all_members = Ash.read!(Membership.Member, actor: admin_user_with_role)
all_custom_fields = Ash.read!(Membership.CustomField, actor: admin_user_with_role) all_custom_fields = Ash.read!(Membership.CustomField, actor: admin_user_with_role)
@ -620,35 +587,6 @@ all_custom_fields = Ash.read!(Membership.CustomField, actor: admin_user_with_rol
find_field = fn name -> Enum.find(all_custom_fields, &(&1.name == name)) end find_field = fn name -> Enum.find(all_custom_fields, &(&1.name == name)) end
find_member = fn email -> Enum.find(all_members, &(&1.email == email)) end find_member = fn email -> Enum.find(all_members, &(&1.email == email)) end
# Assign seed members to groups (idempotent: duplicate create_member_group is skipped)
member_group_assignments = [
{"hans.mueller@example.de", ["Vorstand", "Newsletter"]},
{"greta.schmidt@example.de", ["Jugend", "Newsletter"]},
{"friedrich.wagner@example.de", ["Trainer*innen"]},
{"maria.weber@example.de", ["Newsletter"]},
{"thomas.klein@example.de", ["Newsletter"]}
]
Enum.each(member_group_assignments, fn {email, group_names} ->
member = find_member.(email)
if member do
Enum.each(group_names, fn group_name ->
group = seed_groups[group_name]
if group do
case Membership.create_member_group(
%{member_id: member.id, group_id: group.id},
actor: admin_user_with_role
) do
{:ok, _} -> :ok
{:error, _} -> :ok
end
end
end)
end
end)
# Add custom field values for Hans Müller # Add custom field values for Hans Müller
if hans = find_member.("hans.mueller@example.de") do if hans = find_member.("hans.mueller@example.de") do
[ [
@ -793,7 +731,6 @@ IO.puts(
) )
IO.puts(" - Sample members: Hans, Greta, Friedrich") IO.puts(" - Sample members: Hans, Greta, Friedrich")
IO.puts(" - Groups: Vorstand, Trainer*innen, Jugend, Newsletter (with members assigned)")
IO.puts( IO.puts(
" - Additional users: hans.mueller@example.de, greta.schmidt@example.de, maria.weber@example.de, thomas.klein@example.de" " - Additional users: hans.mueller@example.de, greta.schmidt@example.de, maria.weber@example.de, thomas.klein@example.de"

View file

@ -8,7 +8,6 @@ defmodule Mv.Membership.CustomFieldValidationTest do
- Description length validation (max 500 characters) - Description length validation (max 500 characters)
- Description trimming - Description trimming
- Required vs optional fields - Required vs optional fields
- Value type immutability (cannot be changed after creation)
""" """
use Mv.DataCase, async: true use Mv.DataCase, async: true
@ -208,101 +207,4 @@ defmodule Mv.Membership.CustomFieldValidationTest do
assert [%{field: :value_type}] = changeset.errors assert [%{field: :value_type}] = changeset.errors
end end
end end
describe "value_type immutability" do
test "rejects attempt to change value_type after creation", %{actor: actor} do
# Create custom field with value_type :string
{:ok, custom_field} =
CustomField
|> Ash.Changeset.for_create(:create, %{
name: "test_field",
value_type: :string
})
|> Ash.create(actor: actor)
original_value_type = custom_field.value_type
assert original_value_type == :string
# Attempt to update value_type to :integer
assert {:error, %Ash.Error.Invalid{} = error} =
custom_field
|> Ash.Changeset.for_update(:update, %{
value_type: :integer
})
|> Ash.update(actor: actor)
# Verify error message contains expected text
error_message = Exception.message(error)
assert error_message =~ "cannot be changed" or error_message =~ "value_type"
# Reload and verify value_type remained unchanged
reloaded = Ash.get!(CustomField, custom_field.id, actor: actor)
assert reloaded.value_type == original_value_type
assert reloaded.value_type == :string
end
test "allows updating other fields while value_type remains unchanged", %{actor: actor} do
# Create custom field with value_type :string
{:ok, custom_field} =
CustomField
|> Ash.Changeset.for_create(:create, %{
name: "test_field",
value_type: :string,
description: "Original description"
})
|> Ash.create(actor: actor)
original_value_type = custom_field.value_type
assert original_value_type == :string
# Update other fields (name, description) without touching value_type
{:ok, updated_custom_field} =
custom_field
|> Ash.Changeset.for_update(:update, %{
name: "updated_name",
description: "Updated description"
})
|> Ash.update(actor: actor)
# Verify value_type remained unchanged
assert updated_custom_field.value_type == original_value_type
assert updated_custom_field.value_type == :string
# Verify other fields were updated
assert updated_custom_field.name == "updated_name"
assert updated_custom_field.description == "Updated description"
end
test "rejects value_type change even when other fields are updated", %{actor: actor} do
# Create custom field with value_type :boolean
{:ok, custom_field} =
CustomField
|> Ash.Changeset.for_create(:create, %{
name: "test_field",
value_type: :boolean
})
|> Ash.create(actor: actor)
original_value_type = custom_field.value_type
assert original_value_type == :boolean
# Attempt to update both name and value_type
assert {:error, %Ash.Error.Invalid{} = error} =
custom_field
|> Ash.Changeset.for_update(:update, %{
name: "updated_name",
value_type: :date
})
|> Ash.update(actor: actor)
# Verify error message
error_message = Exception.message(error)
assert error_message =~ "cannot be changed" or error_message =~ "value_type"
# Reload and verify value_type remained unchanged, but name was not updated either
reloaded = Ash.get!(CustomField, custom_field.id, actor: actor)
assert reloaded.value_type == original_value_type
assert reloaded.value_type == :boolean
assert reloaded.name == "test_field"
end
end
end end

View file

@ -39,22 +39,11 @@ defmodule Mv.ConfigVereinfachtTest do
assert Mv.Config.vereinfacht_contact_view_url("123") == nil assert Mv.Config.vereinfacht_contact_view_url("123") == nil
end end
test "returns app contact view URL when API URL is set (derived app URL)" do test "returns URL when API URL is set" do
clear_vereinfacht_env()
clear_vereinfacht_app_url_from_settings()
set_vereinfacht_env("VEREINFACHT_API_URL", "https://api.example.com/api/v1") set_vereinfacht_env("VEREINFACHT_API_URL", "https://api.example.com/api/v1")
assert Mv.Config.vereinfacht_contact_view_url("42") == assert Mv.Config.vereinfacht_contact_view_url("42") ==
"https://app.example.com/en/admin/finances/contacts/42" "https://api.example.com/api/v1/finance-contacts/42"
after
clear_vereinfacht_env()
end
test "returns app contact view URL when VEREINFACHT_APP_URL is set" do
set_vereinfacht_env("VEREINFACHT_APP_URL", "https://app.verein.visuel.dev")
assert Mv.Config.vereinfacht_contact_view_url("abc") ==
"https://app.verein.visuel.dev/en/admin/finances/contacts/abc"
after after
clear_vereinfacht_env() clear_vereinfacht_env()
end end
@ -68,16 +57,5 @@ defmodule Mv.ConfigVereinfachtTest do
System.delete_env("VEREINFACHT_API_URL") System.delete_env("VEREINFACHT_API_URL")
System.delete_env("VEREINFACHT_API_KEY") System.delete_env("VEREINFACHT_API_KEY")
System.delete_env("VEREINFACHT_CLUB_ID") System.delete_env("VEREINFACHT_CLUB_ID")
System.delete_env("VEREINFACHT_APP_URL")
end
defp clear_vereinfacht_app_url_from_settings do
case Mv.Membership.get_settings() do
{:ok, settings} ->
Mv.Membership.update_settings(settings, %{vereinfacht_app_url: nil})
_ ->
:ok
end
end end
end end

View file

@ -132,7 +132,7 @@ defmodule MvWeb.Layouts.SidebarTest do
refute html =~ ~s(role="menuitem") refute html =~ ~s(role="menuitem")
# Footer section should not be rendered # Footer section should not be rendered
refute html =~ "data-theme-toggle" refute html =~ "theme-controller"
refute html =~ "locale-select" refute html =~ "locale-select"
end end
@ -253,8 +253,8 @@ defmodule MvWeb.Layouts.SidebarTest do
# Check for language selector form # Check for language selector form
assert html =~ ~s(action="/set_locale") assert html =~ ~s(action="/set_locale")
# Check for theme toggle (using data attribute instead of class) # Check for theme toggle
assert html =~ "data-theme-toggle" assert has_class?(html, "theme-controller")
# Check for user menu/avatar # Check for user menu/avatar
assert has_class?(html, "avatar") assert has_class?(html, "avatar")
@ -536,7 +536,7 @@ defmodule MvWeb.Layouts.SidebarTest do
assert html =~ ~s(role="group") assert html =~ ~s(role="group")
# Footer section # Footer section
assert html =~ "data-theme-toggle" assert html =~ "theme-controller"
assert html =~ ~s(action="/set_locale") assert html =~ ~s(action="/set_locale")
# Check that critical navigation exists (at least /members) # Check that critical navigation exists (at least /members)
@ -694,8 +694,8 @@ defmodule MvWeb.Layouts.SidebarTest do
test "renders theme toggle" do test "renders theme toggle" do
html = render_sidebar(authenticated_assigns()) html = render_sidebar(authenticated_assigns())
# Toggle is always visible (using data attribute instead of class) # Toggle is always visible
assert html =~ "data-theme-toggle" assert has_class?(html, "theme-controller")
assert html =~ "hero-sun" assert html =~ "hero-sun"
assert html =~ "hero-moon" assert html =~ "hero-moon"
end end

View file

@ -223,7 +223,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
end end
describe "component behavior" do describe "component behavior" do
test "clicking triggers sort event on parent LiveView", %{conn: conn} do test "clicking sends sort message to parent", %{conn: conn} do
conn = conn_with_oidc_user(conn) conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members") {:ok, view, _html} = live(conn, "/members")
@ -232,7 +232,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
|> element("button[phx-value-field='first_name']") |> element("button[phx-value-field='first_name']")
|> render_click() |> render_click()
# The component triggers a "sort" event on the parent LiveView # The component should send a message to the parent LiveView
# This is tested indirectly through the URL change in integration tests # This is tested indirectly through the URL change in integration tests
end end

View file

@ -2,15 +2,11 @@ defmodule MvWeb.AuthControllerTest do
use MvWeb.ConnCase, async: true use MvWeb.ConnCase, async: true
import Phoenix.LiveViewTest import Phoenix.LiveViewTest
import Phoenix.ConnTest import Phoenix.ConnTest
import ExUnit.CaptureLog
# Helper to create an unauthenticated conn (preserves sandbox metadata) # Helper to create an unauthenticated conn (preserves sandbox metadata)
defp build_unauthenticated_conn(authenticated_conn) do defp build_unauthenticated_conn(authenticated_conn) do
# Create new conn but preserve sandbox metadata for database access # Create new conn but preserve sandbox metadata for database access
new_conn = new_conn = build_conn()
build_conn()
|> init_test_session(%{})
|> fetch_flash()
# Copy sandbox metadata from authenticated conn # Copy sandbox metadata from authenticated conn
if authenticated_conn.private[:ecto_sandbox] do if authenticated_conn.private[:ecto_sandbox] do
@ -252,159 +248,4 @@ defmodule MvWeb.AuthControllerTest do
assert to =~ "/auth/user/password/sign_in_with_token" assert to =~ "/auth/user/password/sign_in_with_token"
end end
# OIDC/Rauthy error handling tests
describe "handle_rauthy_failure/2" do
test "Assent.ServerUnreachableError redirects to sign-in with error flash", %{
conn: authenticated_conn
} do
conn = build_unauthenticated_conn(authenticated_conn)
# Create a mock Assent.ServerUnreachableError struct with required fields
error = %Assent.ServerUnreachableError{
http_adapter: Assent.HTTPAdapter.Finch,
request_url: "https://auth.example.com/callback?token=secret123",
reason: %Mint.TransportError{reason: :econnrefused}
}
conn = MvWeb.AuthController.failure(conn, {:rauthy, :callback}, error)
assert redirected_to(conn) == ~p"/sign-in"
assert Phoenix.Flash.get(conn.assigns.flash, :error) ==
"The authentication server is currently unavailable. Please try again later."
end
test "Assent.InvalidResponseError redirects to sign-in with error flash", %{
conn: authenticated_conn
} do
conn = build_unauthenticated_conn(authenticated_conn)
# Create a mock Assent.InvalidResponseError struct with required field
# InvalidResponseError only has :response field (HTTPResponse struct)
error = %Assent.InvalidResponseError{
response: %Assent.HTTPAdapter.HTTPResponse{
status: 400,
headers: [],
body: "invalid_request"
}
}
conn = MvWeb.AuthController.failure(conn, {:rauthy, :callback}, error)
assert redirected_to(conn) == ~p"/sign-in"
assert Phoenix.Flash.get(conn.assigns.flash, :error) ==
"Authentication configuration error. Please contact the administrator."
end
test "unknown reason triggers catch-all and redirects to sign-in with error flash", %{
conn: authenticated_conn
} do
conn = build_unauthenticated_conn(authenticated_conn)
unknown_reason = :oops
conn = MvWeb.AuthController.failure(conn, {:rauthy, :callback}, unknown_reason)
assert redirected_to(conn) == ~p"/sign-in"
assert Phoenix.Flash.get(conn.assigns.flash, :error) ==
"Unable to authenticate with OIDC. Please try again."
end
end
# Logging security tests - ensure no sensitive data is logged
describe "failure/3 logging security" do
test "does not log full URL with query params for Assent.ServerUnreachableError", %{
conn: authenticated_conn
} do
conn = build_unauthenticated_conn(authenticated_conn)
error = %Assent.ServerUnreachableError{
http_adapter: Assent.HTTPAdapter.Finch,
request_url: "https://auth.example.com/callback?token=secret123&code=abc456",
reason: %Mint.TransportError{reason: :econnrefused}
}
log =
capture_log(fn ->
MvWeb.AuthController.failure(conn, {:rauthy, :callback}, error)
end)
# Should log redacted URL (only scheme and host)
assert log =~ "https://auth.example.com"
# Should NOT log query parameters or tokens
refute log =~ "token=secret123"
refute log =~ "code=abc456"
refute log =~ "callback?token"
end
test "does not log sensitive data for Assent.InvalidResponseError", %{
conn: authenticated_conn
} do
conn = build_unauthenticated_conn(authenticated_conn)
error = %Assent.InvalidResponseError{
response: %Assent.HTTPAdapter.HTTPResponse{
status: 400,
headers: [],
body: "invalid_request"
}
}
log =
capture_log(fn ->
MvWeb.AuthController.failure(conn, {:rauthy, :callback}, error)
end)
# Should log error type but not full error details
assert log =~ "Authentication failure"
assert log =~ "rauthy"
# Should not log full error struct with inspect
refute log =~ "Assent.InvalidResponseError"
end
test "does not log full reason for unknown rauthy errors", %{
conn: authenticated_conn
} do
conn = build_unauthenticated_conn(authenticated_conn)
# Simulate an error that might contain sensitive data
error_with_sensitive_data = %{
token: "secret_token_123",
url: "https://example.com/callback?access_token=abc123",
error: :something_went_wrong
}
log =
capture_log(fn ->
MvWeb.AuthController.failure(conn, {:rauthy, :callback}, error_with_sensitive_data)
end)
# Should log error type but not full error details
assert log =~ "Authentication failure"
assert log =~ "rauthy"
# Should NOT log sensitive data
refute log =~ "secret_token_123"
refute log =~ "access_token=abc123"
refute log =~ "callback?access_token"
end
test "logs full reason for non-rauthy activities (password auth)", %{
conn: authenticated_conn
} do
conn = build_unauthenticated_conn(authenticated_conn)
reason = %AshAuthentication.Errors.AuthenticationFailed{
caused_by: %Ash.Error.Forbidden{errors: []}
}
log =
capture_log(fn ->
MvWeb.AuthController.failure(conn, {:password, :sign_in}, reason)
end)
# For non-rauthy activities, full reason is safe to log
assert log =~ "Authentication failure"
assert log =~ "password"
assert log =~ "AuthenticationFailed"
end
end
end end

View file

@ -19,7 +19,6 @@ defmodule MvWeb.GroupLive.FormTest do
test "form renders with empty fields", %{conn: conn} do test "form renders with empty fields", %{conn: conn} do
{:ok, view, html} = live(conn, "/groups/new") {:ok, view, html} = live(conn, "/groups/new")
# OR-chain for i18n (Create Group / Gruppe erstellen)
assert html =~ gettext("Create Group") or html =~ "create" or html =~ "Gruppe erstellen" assert html =~ gettext("Create Group") or html =~ "create" or html =~ "Gruppe erstellen"
assert has_element?(view, "form") assert has_element?(view, "form")
end end
@ -66,7 +65,6 @@ defmodule MvWeb.GroupLive.FormTest do
|> form("#group-form", group: form_data) |> form("#group-form", group: form_data)
|> render_submit() |> render_submit()
# OR-chain for i18n (required/erforderlich) and validation message wording
assert html =~ gettext("required") or html =~ "name" or html =~ "error" or assert html =~ gettext("required") or html =~ "name" or html =~ "error" or
html =~ "erforderlich" html =~ "erforderlich"
end end
@ -82,7 +80,6 @@ defmodule MvWeb.GroupLive.FormTest do
|> form("#group-form", group: form_data) |> form("#group-form", group: form_data)
|> render_submit() |> render_submit()
# OR-chain for i18n (length/Länge) and validation message
assert html =~ "100" or html =~ "length" or html =~ "error" or html =~ "Länge" assert html =~ "100" or html =~ "length" or html =~ "error" or html =~ "Länge"
end end
@ -101,7 +98,6 @@ defmodule MvWeb.GroupLive.FormTest do
|> form("#group-form", group: form_data) |> form("#group-form", group: form_data)
|> render_submit() |> render_submit()
# OR-chain for i18n (length/Länge) and validation message
assert html =~ "500" or html =~ "length" or html =~ "error" or html =~ "Länge" assert html =~ "500" or html =~ "length" or html =~ "error" or html =~ "Länge"
end end
@ -120,7 +116,6 @@ defmodule MvWeb.GroupLive.FormTest do
|> render_submit() |> render_submit()
# Check for a validation error on the name field in a robust way # Check for a validation error on the name field in a robust way
# OR-chain for i18n and validation message (already taken)
assert html =~ "name" or html =~ gettext("has already been taken") assert html =~ "name" or html =~ gettext("has already been taken")
end end
@ -136,7 +131,6 @@ defmodule MvWeb.GroupLive.FormTest do
|> form("#group-form", group: form_data) |> form("#group-form", group: form_data)
|> render_submit() |> render_submit()
# OR-chain for i18n (error/Fehler, invalid/ungültig)
assert html =~ "error" or html =~ "invalid" or html =~ "Fehler" or html =~ "ungültig" assert html =~ "error" or html =~ "invalid" or html =~ "Fehler" or html =~ "ungültig"
end end
end end
@ -202,7 +196,6 @@ defmodule MvWeb.GroupLive.FormTest do
|> form("#group-form", group: form_data) |> form("#group-form", group: form_data)
|> render_submit() |> render_submit()
# OR-chain for i18n (already taken / bereits vergeben) and validation wording
assert html =~ "already" or html =~ "taken" or html =~ "exists" or html =~ "error" or assert html =~ "already" or html =~ "taken" or html =~ "exists" or html =~ "error" or
html =~ "bereits" or html =~ "vergeben" html =~ "bereits" or html =~ "vergeben"
end end
@ -212,7 +205,7 @@ defmodule MvWeb.GroupLive.FormTest do
{:ok, _view, html} = live(conn, "/groups/#{group.slug}/edit") {:ok, _view, html} = live(conn, "/groups/#{group.slug}/edit")
# Slug should not be in form (it's immutable); regex for input element # Slug should not be in form (it's immutable)
refute html =~ ~r/slug.*input/i or html =~ ~r/input.*slug/i refute html =~ ~r/slug.*input/i or html =~ ~r/input.*slug/i
end end
end end

View file

@ -40,14 +40,13 @@ defmodule MvWeb.GroupLive.IndexTest do
assert html =~ "Test Group" assert html =~ "Test Group"
assert html =~ "Test description" assert html =~ "Test description"
# OR-chain for i18n (Members/Mitglieder) and alternate copy for count # Member count should be displayed (0 for empty group)
assert html =~ "0" or html =~ gettext("Members") or html =~ "Mitglieder" assert html =~ "0" or html =~ gettext("Members") or html =~ "Mitglieder"
end end
test "displays 'Create Group' button for admin users", %{conn: conn} do test "displays 'Create Group' button for admin users", %{conn: conn} do
{:ok, _view, html} = live(conn, "/groups") {:ok, _view, html} = live(conn, "/groups")
# OR-chain for i18n (Create Group / Gruppe erstellen) and alternate wording
assert html =~ gettext("Create Group") or html =~ "create" or html =~ "new" or assert html =~ gettext("Create Group") or html =~ "create" or html =~ "new" or
html =~ "Gruppe erstellen" html =~ "Gruppe erstellen"
end end
@ -55,7 +54,7 @@ defmodule MvWeb.GroupLive.IndexTest do
test "displays empty state when no groups exist", %{conn: conn} do test "displays empty state when no groups exist", %{conn: conn} do
{:ok, _view, html} = live(conn, "/groups") {:ok, _view, html} = live(conn, "/groups")
# OR-chain for i18n (No groups / Keine Gruppen) and alternate empty state copy # Should show empty state or empty list message
assert html =~ gettext("No groups") or html =~ "0" or html =~ "empty" or assert html =~ gettext("No groups") or html =~ "0" or html =~ "empty" or
html =~ "Keine Gruppen" html =~ "Keine Gruppen"
end end
@ -77,7 +76,6 @@ defmodule MvWeb.GroupLive.IndexTest do
{:ok, _view, html} = live(conn, "/groups") {:ok, _view, html} = live(conn, "/groups")
# Long description may be truncated in UI
assert html =~ long_description or html =~ String.slice(long_description, 0, 100) assert html =~ long_description or html =~ String.slice(long_description, 0, 100)
end end
end end
@ -111,7 +109,7 @@ defmodule MvWeb.GroupLive.IndexTest do
# Should be able to see groups # Should be able to see groups
assert html =~ gettext("Groups") assert html =~ gettext("Groups")
# Read-only must not see create button (OR for i18n) # Should NOT see create button
refute html =~ gettext("Create Group") or html =~ "create" refute html =~ gettext("Create Group") or html =~ "create"
end end
end end
@ -179,7 +177,7 @@ defmodule MvWeb.GroupLive.IndexTest do
final_count = Agent.get(query_count_agent, & &1) final_count = Agent.get(query_count_agent, & &1)
:telemetry.detach(handler_id) :telemetry.detach(handler_id)
# OR-chain for i18n (Members/Mitglieder) and count display # Member count should be displayed (should be 2)
assert html =~ "2" or html =~ gettext("Members") or html =~ "Mitglieder" assert html =~ "2" or html =~ gettext("Members") or html =~ "Mitglieder"
# Verify query count is reasonable (member count should be calculated efficiently) # Verify query count is reasonable (member count should be calculated efficiently)

View file

@ -62,7 +62,7 @@ defmodule MvWeb.GroupLive.IntegrationTest do
assert html =~ "Updated Workflow Test Group" assert html =~ "Updated Workflow Test Group"
assert html =~ "Updated description" assert html =~ "Updated description"
# OR-chain: slug may appear as UUID or normalized slug in copy # Slug should remain unchanged
assert html =~ original_slug or html =~ "workflow-test-group" assert html =~ original_slug or html =~ "workflow-test-group"
end end
@ -101,7 +101,7 @@ defmodule MvWeb.GroupLive.IntegrationTest do
# View group via slug # View group via slug
{:ok, _view, html} = live(conn, "/groups/#{group.slug}") {:ok, _view, html} = live(conn, "/groups/#{group.slug}")
# OR-chain for i18n (Members/Mitglieder); member names may be first or last # Member count should be 2
assert html =~ "2" or html =~ gettext("Members") or html =~ "Mitglieder" assert html =~ "2" or html =~ gettext("Members") or html =~ "Mitglieder"
assert html =~ member1.first_name or html =~ member1.last_name assert html =~ member1.first_name or html =~ member1.last_name
assert html =~ member2.first_name or html =~ member2.last_name assert html =~ member2.first_name or html =~ member2.last_name

View file

@ -22,13 +22,12 @@ defmodule MvWeb.GroupLive.ShowAccessibilityTest do
|> element("button", "Add Member") |> element("button", "Add Member")
|> render_click() |> render_click()
# OR-chain: at least one of these ARIA/role attributes must be present html = render(view)
assert has_element?(view, "[data-testid=group-show-member-search-input][aria-label]") or
has_element?( # Search input should have proper ARIA attributes
view, assert html =~ ~r/aria-label/ ||
"[data-testid=group-show-member-search-input][aria-autocomplete]" html =~ ~r/aria-autocomplete/ ||
) or html =~ ~r/role=["']combobox["']/
has_element?(view, "[data-testid=group-show-member-search-input][role=combobox]")
end end
test "search input has correct aria-label and aria-autocomplete attributes", %{conn: conn} do test "search input has correct aria-label and aria-autocomplete attributes", %{conn: conn} do
@ -36,14 +35,16 @@ defmodule MvWeb.GroupLive.ShowAccessibilityTest do
{:ok, view, _html} = live(conn, "/groups/#{group.slug}") {:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Open inline input
view view
|> element("button[phx-click='show_add_member_input']") |> element("button", "Add Member")
|> render_click() |> render_click()
assert has_element?( html = render(view)
view,
"[data-testid=group-show-member-search-input][aria-autocomplete=list]" # Search input should have ARIA attributes
) assert html =~ ~r/aria-label.*[Ss]earch.*member/ ||
html =~ ~r/aria-autocomplete=["']list["']/
end end
test "remove button has aria-label with tooltip text", %{conn: conn} do test "remove button has aria-label with tooltip text", %{conn: conn} do
@ -66,7 +67,11 @@ defmodule MvWeb.GroupLive.ShowAccessibilityTest do
{:ok, view, _html} = live(conn, "/groups/#{group.slug}") {:ok, view, _html} = live(conn, "/groups/#{group.slug}")
assert has_element?(view, "[data-testid=group-show-remove-member][aria-label]") html = render(view)
# Remove button should have aria-label
assert html =~ ~r/aria-label.*[Rr]emove/ ||
html =~ ~r/aria-label.*member/i
end end
test "add button has correct aria-label", %{conn: conn} do test "add button has correct aria-label", %{conn: conn} do
@ -74,11 +79,16 @@ defmodule MvWeb.GroupLive.ShowAccessibilityTest do
{:ok, view, _html} = live(conn, "/groups/#{group.slug}") {:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Open inline input
view view
|> element("button[phx-click='show_add_member_input']") |> element("button", "Add Member")
|> render_click() |> render_click()
assert has_element?(view, "[data-testid=group-show-add-selected-members-btn][aria-label]") html = render(view)
# Add button should have aria-label
assert html =~ ~r/aria-label.*[Aa]dd/ ||
html =~ ~r/button.*[Aa]dd/
end end
end end
@ -90,11 +100,16 @@ defmodule MvWeb.GroupLive.ShowAccessibilityTest do
{:ok, view, _html} = live(conn, "/groups/#{group.slug}") {:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Open inline input
view view
|> element("button[phx-click='show_add_member_input']") |> element("button", "Add Member")
|> render_click() |> render_click()
assert has_element?(view, "[data-testid=group-show-member-search-input]") html = render(view)
# Inline add member area should have focusable elements
assert html =~ ~r/input|button/ ||
html =~ "#member-search-input"
end end
test "inline input can be closed", %{conn: conn} do test "inline input can be closed", %{conn: conn} do
@ -102,11 +117,17 @@ defmodule MvWeb.GroupLive.ShowAccessibilityTest do
{:ok, view, _html} = live(conn, "/groups/#{group.slug}") {:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Open inline input
view view
|> element("button[phx-click='show_add_member_input']") |> element("button", "Add Member")
|> render_click() |> render_click()
assert has_element?(view, "[data-testid=group-show-member-search-input]") assert has_element?(view, "#member-search-input")
# Click Add Member button again to close (or add a member to close it)
# For now, we verify the input is visible when opened
html = render(view)
assert html =~ "#member-search-input" || has_element?(view, "#member-search-input")
end end
test "enter/space activates buttons when focused", %{conn: conn} do test "enter/space activates buttons when focused", %{conn: conn} do
@ -127,14 +148,17 @@ defmodule MvWeb.GroupLive.ShowAccessibilityTest do
{:ok, view, _html} = live(conn, "/groups/#{group.slug}") {:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Open inline input
view view
|> element("button[phx-click='show_add_member_input']") |> element("button", "Add Member")
|> render_click() |> render_click()
# Select member
view view
|> element("[data-testid=group-show-member-search-input]") |> element("#member-search-input")
|> render_focus() |> render_focus()
# phx-change is on the form, so we need to trigger it via the form
view view
|> element("form[phx-change='search_members']") |> element("form[phx-change='search_members']")
|> render_change(%{"member_search" => "Bob"}) |> render_change(%{"member_search" => "Bob"})
@ -143,11 +167,14 @@ defmodule MvWeb.GroupLive.ShowAccessibilityTest do
|> element("[data-member-id='#{member.id}']") |> element("[data-member-id='#{member.id}']")
|> render_click() |> render_click()
# Add button should be enabled and clickable
view view
|> element("[data-testid=group-show-add-selected-members-btn]") |> element("button[phx-click='add_selected_members']")
|> render_click() |> render_click()
assert has_element?(view, "[data-testid=group-show-members-table]", "Bob") # Should succeed (member should appear in list)
html = render(view)
assert html =~ "Bob"
end end
test "focus management: focus is set to input when opened", %{conn: conn} do test "focus management: focus is set to input when opened", %{conn: conn} do
@ -157,11 +184,16 @@ defmodule MvWeb.GroupLive.ShowAccessibilityTest do
{:ok, view, _html} = live(conn, "/groups/#{group.slug}") {:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Open inline input
view view
|> element("button[phx-click='show_add_member_input']") |> element("button", "Add Member")
|> render_click() |> render_click()
assert has_element?(view, "[data-testid=group-show-member-search-input]") html = render(view)
# Input should be visible and focusable
assert html =~ "#member-search-input" ||
html =~ ~r/autofocus|tabindex/
end end
end end
@ -171,11 +203,16 @@ defmodule MvWeb.GroupLive.ShowAccessibilityTest do
{:ok, view, _html} = live(conn, "/groups/#{group.slug}") {:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Open inline input
view view
|> element("button[phx-click='show_add_member_input']") |> element("button", "Add Member")
|> render_click() |> render_click()
assert has_element?(view, "[data-testid=group-show-member-search-input][aria-label]") html = render(view)
# Input should have aria-label
assert html =~ ~r/aria-label.*[Ss]earch.*member/ ||
html =~ ~r/aria-label/
end end
test "search results are properly announced", %{conn: conn} do test "search results are properly announced", %{conn: conn} do
@ -194,20 +231,27 @@ defmodule MvWeb.GroupLive.ShowAccessibilityTest do
{:ok, view, _html} = live(conn, "/groups/#{group.slug}") {:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Open inline input
view view
|> element("button[phx-click='show_add_member_input']") |> element("button", "Add Member")
|> render_click() |> render_click()
# Search
view view
|> element("[data-testid=group-show-member-search-input]") |> element("#member-search-input")
|> render_focus() |> render_focus()
# phx-change is on the form, so we need to trigger it via the form
view view
|> element("form[phx-change='search_members']") |> element("form[phx-change='search_members']")
|> render_change(%{"member_search" => "Charlie"}) |> render_change(%{"member_search" => "Charlie"})
assert has_element?(view, "#member-dropdown[role=listbox]") html = render(view)
assert has_element?(view, "#member-dropdown", "Charlie")
# Search results should have proper ARIA attributes
assert html =~ ~r/role=["']listbox["']/ ||
html =~ ~r/role=["']option["']/ ||
html =~ "Charlie"
end end
test "flash messages are properly announced", %{conn: conn} do test "flash messages are properly announced", %{conn: conn} do
@ -226,14 +270,16 @@ defmodule MvWeb.GroupLive.ShowAccessibilityTest do
{:ok, view, _html} = live(conn, "/groups/#{group.slug}") {:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Add member
view view
|> element("button[phx-click='show_add_member_input']") |> element("button", "Add Member")
|> render_click() |> render_click()
view view
|> element("[data-testid=group-show-member-search-input]") |> element("#member-search-input")
|> render_focus() |> render_focus()
# phx-change is on the form, so we need to trigger it via the form
view view
|> element("form[phx-change='search_members']") |> element("form[phx-change='search_members']")
|> render_change(%{"member_search" => "David"}) |> render_change(%{"member_search" => "David"})
@ -243,10 +289,13 @@ defmodule MvWeb.GroupLive.ShowAccessibilityTest do
|> render_click() |> render_click()
view view
|> element("[data-testid=group-show-add-selected-members-btn]") |> element("button[phx-click='add_selected_members']")
|> render_click() |> render_click()
assert has_element?(view, "[data-testid=group-show-members-table]", "David") html = render(view)
# Member should appear in list (no flash message)
assert html =~ "David"
end end
end end
end end

View file

@ -34,8 +34,9 @@ defmodule MvWeb.GroupLive.ShowAddMemberTest do
select_member(view, member) select_member(view, member)
add_selected(view) add_selected(view)
assert has_element?(view, "[data-testid=group-show-members-table]", "Alice") html = render(view)
assert has_element?(view, "[data-testid=group-show-members-table]", "Johnson") assert html =~ "Alice"
assert html =~ "Johnson"
end end
test "member is successfully added to group (verified in list)", %{conn: conn} do test "member is successfully added to group (verified in list)", %{conn: conn} do
@ -54,14 +55,16 @@ defmodule MvWeb.GroupLive.ShowAddMemberTest do
{:ok, view, _html} = live(conn, "/groups/#{group.slug}") {:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Open inline input and add member
view view
|> element("button[phx-click='show_add_member_input']") |> element("button", "Add Member")
|> render_click() |> render_click()
view view
|> element("[data-testid=group-show-member-search-input]") |> element("#member-search-input")
|> render_focus() |> render_focus()
# phx-change is on the form, so we need to trigger it via the form
view view
|> element("form[phx-change='search_members']") |> element("form[phx-change='search_members']")
|> render_change(%{"member_search" => "Bob"}) |> render_change(%{"member_search" => "Bob"})
@ -71,11 +74,14 @@ defmodule MvWeb.GroupLive.ShowAddMemberTest do
|> render_click() |> render_click()
view view
|> element("[data-testid=group-show-add-selected-members-btn]") |> element("button[phx-click='add_selected_members']")
|> render_click() |> render_click()
assert has_element?(view, "[data-testid=group-show-members-table]", "Bob") html = render(view)
assert has_element?(view, "[data-testid=group-show-members-table]", "Smith")
# Verify member appears in group list (no success flash message)
assert html =~ "Bob"
assert html =~ "Smith"
end end
test "group member list updates automatically after add", %{conn: conn} do test "group member list updates automatically after add", %{conn: conn} do
@ -92,18 +98,21 @@ defmodule MvWeb.GroupLive.ShowAddMemberTest do
actor: system_actor actor: system_actor
) )
{:ok, view, _html} = live(conn, "/groups/#{group.slug}") {:ok, view, html} = live(conn, "/groups/#{group.slug}")
refute has_element?(view, "[data-testid=group-show-members-table]", "Charlie") # Initially member should NOT be in list
refute html =~ "Charlie"
# Add member
view view
|> element("button[phx-click='show_add_member_input']") |> element("button", "Add Member")
|> render_click() |> render_click()
view view
|> element("[data-testid=group-show-member-search-input]") |> element("#member-search-input")
|> render_focus() |> render_focus()
# phx-change is on the form, so we need to trigger it via the form
view view
|> element("form[phx-change='search_members']") |> element("form[phx-change='search_members']")
|> render_change(%{"member_search" => "Charlie"}) |> render_change(%{"member_search" => "Charlie"})
@ -113,11 +122,13 @@ defmodule MvWeb.GroupLive.ShowAddMemberTest do
|> render_click() |> render_click()
view view
|> element("[data-testid=group-show-add-selected-members-btn]") |> element("button[phx-click='add_selected_members']")
|> render_click() |> render_click()
assert has_element?(view, "[data-testid=group-show-members-table]", "Charlie") # Member should now appear in list
assert has_element?(view, "[data-testid=group-show-members-table]", "Brown") html = render(view)
assert html =~ "Charlie"
assert html =~ "Brown"
end end
test "member count updates automatically after add", %{conn: conn} do test "member count updates automatically after add", %{conn: conn} do
@ -141,11 +152,11 @@ defmodule MvWeb.GroupLive.ShowAddMemberTest do
# Add member # Add member
view view
|> element("button[phx-click='show_add_member_input']") |> element("button", "Add Member")
|> render_click() |> render_click()
view view
|> element("[data-testid=group-show-member-search-input]") |> element("#member-search-input")
|> render_focus() |> render_focus()
# phx-change is on the form, so we need to trigger it via the form # phx-change is on the form, so we need to trigger it via the form
@ -158,7 +169,7 @@ defmodule MvWeb.GroupLive.ShowAddMemberTest do
|> render_click() |> render_click()
view view
|> element("[data-testid=group-show-add-selected-members-btn]") |> element("button[phx-click='add_selected_members']")
|> render_click() |> render_click()
# Count should have increased # Count should have increased
@ -185,14 +196,14 @@ defmodule MvWeb.GroupLive.ShowAddMemberTest do
# Open inline input # Open inline input
view view
|> element("button[phx-click='show_add_member_input']") |> element("button", "Add Member")
|> render_click() |> render_click()
assert has_element?(view, "[data-testid=group-show-member-search-input]") assert has_element?(view, "#member-search-input")
# Add member # Add member
view view
|> element("[data-testid=group-show-member-search-input]") |> element("#member-search-input")
|> render_focus() |> render_focus()
# phx-change is on the form, so we need to trigger it via the form # phx-change is on the form, so we need to trigger it via the form
@ -205,10 +216,11 @@ defmodule MvWeb.GroupLive.ShowAddMemberTest do
|> render_click() |> render_click()
view view
|> element("[data-testid=group-show-add-selected-members-btn]") |> element("button[phx-click='add_selected_members']")
|> render_click() |> render_click()
refute has_element?(view, "[data-testid=group-show-member-search-input]") # Inline input should be closed (Add Member button should be visible again)
refute has_element?(view, "#member-search-input")
end end
test "Cancel button closes inline add member area without adding", %{conn: conn} do test "Cancel button closes inline add member area without adding", %{conn: conn} do
@ -217,7 +229,7 @@ defmodule MvWeb.GroupLive.ShowAddMemberTest do
{:ok, view, _html} = live(conn, "/groups/#{group.slug}") {:ok, view, _html} = live(conn, "/groups/#{group.slug}")
open_add_member(view) open_add_member(view)
assert has_element?(view, "[data-testid=group-show-member-search-input]") assert has_element?(view, "#member-search-input")
assert has_element?(view, "button[phx-click='hide_add_member_input']") assert has_element?(view, "button[phx-click='hide_add_member_input']")
cancel_add_member(view) cancel_add_member(view)
@ -251,7 +263,7 @@ defmodule MvWeb.GroupLive.ShowAddMemberTest do
# Try to add same member again # Try to add same member again
view view
|> element("button[phx-click='show_add_member_input']") |> element("button", "Add Member")
|> render_click() |> render_click()
# Member should not appear in search (filtered out) # Member should not appear in search (filtered out)
@ -269,12 +281,12 @@ defmodule MvWeb.GroupLive.ShowAddMemberTest do
|> render_click() |> render_click()
view view
|> element("[data-testid=group-show-add-selected-members-btn]") |> element("button", "Add")
|> render_click() |> render_click()
# OR-chain for i18n and alternate error wording (already in group / duplicate) # Should show error
html = render(view) html = render(view)
assert html =~ gettext("already in group") or html =~ ~r/already.*group|duplicate/i assert html =~ gettext("already in group") || html =~ ~r/already.*group|duplicate/i
end end
end end
@ -288,7 +300,7 @@ defmodule MvWeb.GroupLive.ShowAddMemberTest do
# Open inline input # Open inline input
view view
|> element("button[phx-click='show_add_member_input']") |> element("button", "Add Member")
|> render_click() |> render_click()
# Try to add with invalid member ID (if possible) # Try to add with invalid member ID (if possible)
@ -319,10 +331,11 @@ defmodule MvWeb.GroupLive.ShowAddMemberTest do
# Open inline input # Open inline input
view view
|> element("button[phx-click='show_add_member_input']") |> element("button", "Add Member")
|> render_click() |> render_click()
assert has_element?(view, "[data-testid=group-show-member-search-input]") # Inline input should be open
assert has_element?(view, "#member-search-input")
# If error occurs, inline input should remain open # If error occurs, inline input should remain open
# (Implementation will handle this) # (Implementation will handle this)
@ -335,10 +348,11 @@ defmodule MvWeb.GroupLive.ShowAddMemberTest do
# Open inline input # Open inline input
view view
|> element("button[phx-click='show_add_member_input']") |> element("button", "Add Member")
|> render_click() |> render_click()
assert has_element?(view, "[data-testid=group-show-add-selected-members-btn][disabled]") # Add button should be disabled
assert has_element?(view, "button[phx-click='add_selected_members'][disabled]")
end end
end end
@ -361,11 +375,11 @@ defmodule MvWeb.GroupLive.ShowAddMemberTest do
# Add member to empty group # Add member to empty group
view view
|> element("button[phx-click='show_add_member_input']") |> element("button", "Add Member")
|> render_click() |> render_click()
view view
|> element("[data-testid=group-show-member-search-input]") |> element("#member-search-input")
|> render_focus() |> render_focus()
# phx-change is on the form, so we need to trigger it via the form # phx-change is on the form, so we need to trigger it via the form
@ -378,10 +392,12 @@ defmodule MvWeb.GroupLive.ShowAddMemberTest do
|> render_click() |> render_click()
view view
|> element("[data-testid=group-show-add-selected-members-btn]") |> element("button[phx-click='add_selected_members']")
|> render_click() |> render_click()
assert has_element?(view, "[data-testid=group-show-members-table]", "Henry") # Member should be added
html = render(view)
assert html =~ "Henry"
end end
test "add works when member is already in other groups", %{conn: conn} do test "add works when member is already in other groups", %{conn: conn} do
@ -408,11 +424,11 @@ defmodule MvWeb.GroupLive.ShowAddMemberTest do
# Add same member to group2 (should work) # Add same member to group2 (should work)
view view
|> element("button[phx-click='show_add_member_input']") |> element("button", "Add Member")
|> render_click() |> render_click()
view view
|> element("[data-testid=group-show-member-search-input]") |> element("#member-search-input")
|> render_focus() |> render_focus()
# phx-change is on the form, so we need to trigger it via the form # phx-change is on the form, so we need to trigger it via the form
@ -425,10 +441,12 @@ defmodule MvWeb.GroupLive.ShowAddMemberTest do
|> render_click() |> render_click()
view view
|> element("[data-testid=group-show-add-selected-members-btn]") |> element("button[phx-click='add_selected_members']")
|> render_click() |> render_click()
assert has_element?(view, "[data-testid=group-show-members-table]", "Isabel") # Member should be added to group2
html = render(view)
assert html =~ "Isabel"
end end
end end

View file

@ -22,18 +22,18 @@ defmodule MvWeb.GroupLive.ShowAddRemoveMembersTest do
test "Add Member button is visible for users with :update permission", %{conn: conn} do test "Add Member button is visible for users with :update permission", %{conn: conn} do
group = Fixtures.group_fixture() group = Fixtures.group_fixture()
{:ok, view, _html} = live(conn, "/groups/#{group.slug}") {:ok, _view, html} = live(conn, "/groups/#{group.slug}")
assert has_element?(view, "button[phx-click='show_add_member_input']") assert html =~ gettext("Add Member") or html =~ "Add Member"
end end
@tag role: :read_only @tag role: :read_only
test "Add Member button is NOT visible for users without :update permission", %{conn: conn} do test "Add Member button is NOT visible for users without :update permission", %{conn: conn} do
group = Fixtures.group_fixture() group = Fixtures.group_fixture()
{:ok, view, _html} = live(conn, "/groups/#{group.slug}") {:ok, _view, html} = live(conn, "/groups/#{group.slug}")
refute has_element?(view, "button[phx-click='show_add_member_input']") refute html =~ gettext("Add Member")
end end
test "Add Member button is positioned above member table", %{conn: conn} do test "Add Member button is positioned above member table", %{conn: conn} do
@ -61,7 +61,11 @@ defmodule MvWeb.GroupLive.ShowAddRemoveMembersTest do
{:ok, view, _html} = live(conn, "/groups/#{group.slug}") {:ok, view, _html} = live(conn, "/groups/#{group.slug}")
assert has_element?(view, "[data-testid=group-show-remove-member]") # Remove button should exist (can be icon button with trash icon)
html = render(view)
assert html =~ "Remove" or html =~ "remove" or html =~ "trash" or
html =~ ~r/hero-trash|hero-x-mark/
end end
@tag role: :read_only @tag role: :read_only
@ -74,9 +78,10 @@ defmodule MvWeb.GroupLive.ShowAddRemoveMembersTest do
actor: system_actor actor: system_actor
) )
{:ok, view, _html} = live(conn, "/groups/#{group.slug}") {:ok, _view, html} = live(conn, "/groups/#{group.slug}")
refute has_element?(view, "[data-testid=group-show-remove-member]") # Remove button should NOT exist (check for trash icon or remove button specifically)
refute html =~ "hero-trash" or html =~ ~r/<button[^>]*remove_member/
end end
end end
@ -105,7 +110,10 @@ defmodule MvWeb.GroupLive.ShowAddRemoveMembersTest do
|> element("button", gettext("Add Member")) |> element("button", gettext("Add Member"))
|> render_click() |> render_click()
assert has_element?(view, "[data-testid=group-show-member-search-input]") html = render(view)
assert html =~ gettext("Search for a member...") ||
html =~ ~r/search.*member/i
end end
test "Add button (plus icon) is disabled until member selected", %{conn: conn} do test "Add button (plus icon) is disabled until member selected", %{conn: conn} do
@ -113,11 +121,15 @@ defmodule MvWeb.GroupLive.ShowAddRemoveMembersTest do
{:ok, view, _html} = live(conn, "/groups/#{group.slug}") {:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Open inline input
view view
|> element("button[phx-click='show_add_member_input']") |> element("button", gettext("Add Member"))
|> render_click() |> render_click()
assert has_element?(view, "[data-testid=group-show-add-selected-members-btn][disabled]") html = render(view)
# Add button should exist and be disabled initially
assert has_element?(view, "button[phx-click='add_selected_members'][disabled]") ||
html =~ ~r/disabled/
end end
end end
end end

View file

@ -52,7 +52,8 @@ defmodule MvWeb.GroupLive.ShowAuthorizationTest do
|> render_click() |> render_click()
# Should succeed (admin has :update permission, member should appear in list) # Should succeed (admin has :update permission, member should appear in list)
assert has_element?(view, "[data-testid=group-show-members-table]", "Alice") html = render(view)
assert html =~ "Alice"
end end
@tag role: :read_only @tag role: :read_only
@ -77,7 +78,9 @@ defmodule MvWeb.GroupLive.ShowAuthorizationTest do
# Note: If button is hidden, we can't click it, but we test the event handler # Note: If button is hidden, we can't click it, but we test the event handler
# by trying to send the event directly if possible # by trying to send the event directly if possible
refute has_element?(view, "button[phx-click='show_add_member_input']") # For now, we verify that the button is not visible
html = render(view)
refute html =~ "Add Member"
end end
test "remove member event handler checks :update permission", %{conn: conn} do test "remove member event handler checks :update permission", %{conn: conn} do
@ -100,11 +103,14 @@ defmodule MvWeb.GroupLive.ShowAuthorizationTest do
{:ok, view, _html} = live(conn, "/groups/#{group.slug}") {:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Remove member (should succeed for admin)
view view
|> element("[data-testid=group-show-remove-member][phx-value-member_id='#{member.id}']") |> element("button[phx-click='remove_member'][phx-value-member_id='#{member.id}']")
|> render_click() |> render_click()
refute has_element?(view, "[data-testid=group-show-members-table]", "Charlie") # Should succeed (member should no longer be in list)
html = render(view)
refute html =~ "Charlie"
end end
@tag role: :read_only @tag role: :read_only
@ -128,7 +134,11 @@ defmodule MvWeb.GroupLive.ShowAuthorizationTest do
{:ok, view, _html} = live(conn, "/groups/#{group.slug}") {:ok, view, _html} = live(conn, "/groups/#{group.slug}")
refute has_element?(view, "[data-testid=group-show-remove-member]") # Remove button should not be visible
html = render(view)
# Read-only user should NOT see Remove button (check for trash icon or remove button specifically)
refute html =~ "hero-trash" or html =~ ~r/<button[^>]*remove_member/
end end
test "error flash message on unauthorized access", %{conn: conn} do test "error flash message on unauthorized access", %{conn: conn} do
@ -164,10 +174,10 @@ defmodule MvWeb.GroupLive.ShowAuthorizationTest do
actor: system_actor actor: system_actor
) )
{:ok, view, _html} = live(conn, "/groups/#{group.slug}") {:ok, _view, html} = live(conn, "/groups/#{group.slug}")
assert has_element?(view, "button[phx-click='show_add_member_input']") # Admin should see buttons
assert has_element?(view, "[data-testid=group-show-remove-member]") assert html =~ "Add Member" || html =~ "Remove"
end end
@tag role: :read_only @tag role: :read_only
@ -175,9 +185,10 @@ defmodule MvWeb.GroupLive.ShowAuthorizationTest do
_system_actor = Mv.Helpers.SystemActor.get_system_actor() _system_actor = Mv.Helpers.SystemActor.get_system_actor()
group = Fixtures.group_fixture() group = Fixtures.group_fixture()
{:ok, view, _html} = live(conn, "/groups/#{group.slug}") {:ok, _view, html} = live(conn, "/groups/#{group.slug}")
refute has_element?(view, "button[phx-click='show_add_member_input']") # Read-only user should NOT see Add Member button
refute html =~ "Add Member"
end end
@tag role: :read_only @tag role: :read_only
@ -199,18 +210,21 @@ defmodule MvWeb.GroupLive.ShowAuthorizationTest do
actor: system_actor actor: system_actor
) )
{:ok, view, _html} = live(conn, "/groups/#{group.slug}") {:ok, _view, html} = live(conn, "/groups/#{group.slug}")
refute has_element?(view, "[data-testid=group-show-remove-member]") # Read-only user should NOT see Remove button (check for trash icon or remove button specifically)
refute html =~ "hero-trash" or html =~ ~r/<button[^>]*remove_member/
end end
@tag role: :read_only @tag role: :read_only
test "inline add member area cannot be opened for unauthorized users", %{conn: conn} do test "inline add member area cannot be opened for unauthorized users", %{conn: conn} do
group = Fixtures.group_fixture() group = Fixtures.group_fixture()
{:ok, view, _html} = live(conn, "/groups/#{group.slug}") {:ok, _view, html} = live(conn, "/groups/#{group.slug}")
refute has_element?(view, "button[phx-click='show_add_member_input']") # Inline input should not be accessible (button hidden)
refute html =~ "Add Member"
refute html =~ "#member-search-input"
end end
end end

View file

@ -305,8 +305,9 @@ defmodule MvWeb.GroupLive.ShowIntegrationTest do
|> render_click() |> render_click()
# Both members should be in list # Both members should be in list
assert has_element?(view, "[data-testid=group-show-members-table]", "Frank") html = render(view)
assert has_element?(view, "[data-testid=group-show-members-table]", "Grace") assert html =~ "Frank"
assert html =~ "Grace"
end end
test "multiple members can be removed sequentially", %{conn: conn} do test "multiple members can be removed sequentially", %{conn: conn} do
@ -342,11 +343,11 @@ defmodule MvWeb.GroupLive.ShowIntegrationTest do
actor: system_actor actor: system_actor
) )
{:ok, view, _html} = live(conn, "/groups/#{group.slug}") {:ok, view, html} = live(conn, "/groups/#{group.slug}")
# Both should be in list initially # Both should be in list initially
assert has_element?(view, "[data-testid=group-show-members-table]", "Henry") assert html =~ "Henry"
assert has_element?(view, "[data-testid=group-show-members-table]", "Isabel") assert html =~ "Isabel"
# Remove first member # Remove first member
view view
@ -359,8 +360,9 @@ defmodule MvWeb.GroupLive.ShowIntegrationTest do
|> render_click() |> render_click()
# Both should be removed # Both should be removed
refute has_element?(view, "[data-testid=group-show-members-table]", "Henry") html = render(view)
refute has_element?(view, "[data-testid=group-show-members-table]", "Isabel") refute html =~ "Henry"
refute html =~ "Isabel"
end end
test "add and remove can be mixed", %{conn: conn} do test "add and remove can be mixed", %{conn: conn} do
@ -422,8 +424,9 @@ defmodule MvWeb.GroupLive.ShowIntegrationTest do
|> render_click() |> render_click()
# Only member2 should remain # Only member2 should remain
refute has_element?(view, "[data-testid=group-show-members-table]", "Jack") html = render(view)
assert has_element?(view, "[data-testid=group-show-members-table]", "Kate") refute html =~ "Jack"
assert html =~ "Kate"
end end
end end
end end

View file

@ -34,16 +34,21 @@ defmodule MvWeb.GroupLive.ShowMemberSearchTest do
{:ok, view, _html} = live(conn, "/groups/#{group.slug}") {:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Open inline input
view view
|> element("button[phx-click='show_add_member_input']") |> element("button", "Add Member")
|> render_click() |> render_click()
# Type exact name
# phx-change is on the form, so we need to trigger it via the form
view view
|> element("form[phx-change='search_members']") |> element("form[phx-change='search_members']")
|> render_change(%{"member_search" => "Jonathan"}) |> render_change(%{"member_search" => "Jonathan"})
assert has_element?(view, "#member-dropdown", "Jonathan") html = render(view)
assert has_element?(view, "#member-dropdown", "Smith")
assert html =~ "Jonathan"
assert html =~ "Smith"
end end
test "search finds member by partial name (fuzzy)", %{conn: conn} do test "search finds member by partial name (fuzzy)", %{conn: conn} do
@ -63,16 +68,22 @@ defmodule MvWeb.GroupLive.ShowMemberSearchTest do
{:ok, view, _html} = live(conn, "/groups/#{group.slug}") {:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Open inline input
view view
|> element("button[phx-click='show_add_member_input']") |> element("button", "Add Member")
|> render_click() |> render_click()
# Type partial name
# phx-change is on the form, so we need to trigger it via the form
view view
|> element("form[phx-change='search_members']") |> element("form[phx-change='search_members']")
|> render_change(%{"member_search" => "Jon"}) |> render_change(%{"member_search" => "Jon"})
assert has_element?(view, "#member-dropdown", "Jonathan") html = render(view)
assert has_element?(view, "#member-dropdown", "Smith")
# Fuzzy search should find Jonathan
assert html =~ "Jonathan"
assert html =~ "Smith"
end end
test "search finds member by email", %{conn: conn} do test "search finds member by email", %{conn: conn} do
@ -92,17 +103,22 @@ defmodule MvWeb.GroupLive.ShowMemberSearchTest do
{:ok, view, _html} = live(conn, "/groups/#{group.slug}") {:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Open inline input
view view
|> element("button[phx-click='show_add_member_input']") |> element("button", "Add Member")
|> render_click() |> render_click()
# Search by email
# phx-change is on the form, so we need to trigger it via the form
view view
|> element("form[phx-change='search_members']") |> element("form[phx-change='search_members']")
|> render_change(%{"member_search" => "alice.johnson"}) |> render_change(%{"member_search" => "alice.johnson"})
assert has_element?(view, "#member-dropdown", "Alice") html = render(view)
assert has_element?(view, "#member-dropdown", "Johnson")
assert has_element?(view, "#member-dropdown", "alice.johnson@example.com") assert html =~ "Alice"
assert html =~ "Johnson"
assert html =~ "alice.johnson@example.com"
end end
test "dropdown shows member name and email", %{conn: conn} do test "dropdown shows member name and email", %{conn: conn} do
@ -137,9 +153,11 @@ defmodule MvWeb.GroupLive.ShowMemberSearchTest do
|> element("form[phx-change='search_members']") |> element("form[phx-change='search_members']")
|> render_change(%{"member_search" => "Bob"}) |> render_change(%{"member_search" => "Bob"})
assert has_element?(view, "#member-dropdown", "Bob") html = render(view)
assert has_element?(view, "#member-dropdown", "Williams")
assert has_element?(view, "#member-dropdown", "bob@example.com") assert html =~ "Bob"
assert html =~ "Williams"
assert html =~ "bob@example.com"
end end
test "ComboBox hook works (focus opens dropdown)", %{conn: conn} do test "ComboBox hook works (focus opens dropdown)", %{conn: conn} do
@ -159,15 +177,20 @@ defmodule MvWeb.GroupLive.ShowMemberSearchTest do
{:ok, view, _html} = live(conn, "/groups/#{group.slug}") {:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Open inline input
view view
|> element("button[phx-click='show_add_member_input']") |> element("button", "Add Member")
|> render_click() |> render_click()
# Focus input
view view
|> element("[data-testid=group-show-member-search-input]") |> element("#member-search-input")
|> render_focus() |> render_focus()
assert has_element?(view, "#member-dropdown[role=listbox]") html = render(view)
# Dropdown should be visible
assert html =~ ~r/role="listbox"/ || html =~ "listbox"
end end
end end
@ -205,16 +228,21 @@ defmodule MvWeb.GroupLive.ShowMemberSearchTest do
{:ok, view, _html} = live(conn, "/groups/#{group.slug}") {:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Open inline input
view view
|> element("button[phx-click='show_add_member_input']") |> element("button", "Add Member")
|> render_click() |> render_click()
# Search for "David"
# phx-change is on the form, so we need to trigger it via the form
view view
|> element("form[phx-change='search_members']") |> element("form[phx-change='search_members']")
|> render_change(%{"member_search" => "David"}) |> render_change(%{"member_search" => "David"})
assert has_element?(view, "#member-dropdown", "Anderson") # Assert only on dropdown (available members), not the members table
refute has_element?(view, "#member-dropdown", "Miller") dropdown_html = view |> element("#member-dropdown") |> render()
assert dropdown_html =~ "Anderson"
refute dropdown_html =~ "Miller"
end end
test "search filters correctly when group has many members", %{conn: conn} do test "search filters correctly when group has many members", %{conn: conn} do
@ -252,18 +280,23 @@ defmodule MvWeb.GroupLive.ShowMemberSearchTest do
{:ok, view, _html} = live(conn, "/groups/#{group.slug}") {:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Open inline input
view view
|> element("button[phx-click='show_add_member_input']") |> element("button", "Add Member")
|> render_click() |> render_click()
# Search
# phx-change is on the form, so we need to trigger it via the form
view view
|> element("form[phx-change='search_members']") |> element("form[phx-change='search_members']")
|> render_change(%{"member_search" => "Available"}) |> render_change(%{"member_search" => "Available"})
assert has_element?(view, "#member-dropdown", "Available") # Assert only on dropdown (available members), not the members table
assert has_element?(view, "#member-dropdown", "Member") dropdown_html = view |> element("#member-dropdown") |> render()
refute has_element?(view, "#member-dropdown", "Member1") assert dropdown_html =~ "Available"
refute has_element?(view, "#member-dropdown", "Member2") assert dropdown_html =~ "Member"
refute dropdown_html =~ "Member1"
refute dropdown_html =~ "Member2"
end end
test "search shows no results when all available members are already in group", %{conn: conn} do test "search shows no results when all available members are already in group", %{conn: conn} do
@ -288,14 +321,18 @@ defmodule MvWeb.GroupLive.ShowMemberSearchTest do
{:ok, view, _html} = live(conn, "/groups/#{group.slug}") {:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Open inline input
view view
|> element("button[phx-click='show_add_member_input']") |> element("button", "Add Member")
|> render_click() |> render_click()
# Search
# phx-change is on the form, so we need to trigger it via the form
view view
|> element("form[phx-change='search_members']") |> element("form[phx-change='search_members']")
|> render_change(%{"member_search" => "Only"}) |> render_change(%{"member_search" => "Only"})
# When no available members, dropdown is not rendered (length(@available_members) == 0)
refute has_element?(view, "#member-dropdown") refute has_element?(view, "#member-dropdown")
end end
end end

View file

@ -31,15 +31,19 @@ defmodule MvWeb.GroupLive.ShowRemoveMemberTest do
actor: system_actor actor: system_actor
) )
{:ok, view, _html} = live(conn, "/groups/#{group.slug}") {:ok, view, html} = live(conn, "/groups/#{group.slug}")
assert has_element?(view, "[data-testid=group-show-members-table]", "Alice") # Member should be in list initially
assert html =~ "Alice"
# Click Remove button
view view
|> element("[data-testid=group-show-remove-member][phx-value-member_id='#{member.id}']") |> element("button[phx-click='remove_member'][phx-value-member_id='#{member.id}']")
|> render_click() |> render_click()
refute has_element?(view, "[data-testid=group-show-members-table]", "Alice") # Member should no longer be in list (no success flash message)
html = render(view)
refute html =~ "Alice"
end end
test "member is successfully removed from group (verified in list)", %{conn: conn} do test "member is successfully removed from group (verified in list)", %{conn: conn} do
@ -60,15 +64,20 @@ defmodule MvWeb.GroupLive.ShowRemoveMemberTest do
actor: system_actor actor: system_actor
) )
{:ok, view, _html} = live(conn, "/groups/#{group.slug}") {:ok, view, html} = live(conn, "/groups/#{group.slug}")
assert has_element?(view, "[data-testid=group-show-members-table]", "Bob") # Member should be in list initially
assert html =~ "Bob"
# Remove member
view view
|> element("[data-testid=group-show-remove-member][phx-value-member_id='#{member.id}']") |> element("button[phx-click='remove_member'][phx-value-member_id='#{member.id}']")
|> render_click() |> render_click()
refute has_element?(view, "[data-testid=group-show-members-table]", "Bob") html = render(view)
# Member should no longer be in list (no success flash message)
refute html =~ "Bob"
end end
test "group member list updates automatically after remove", %{conn: conn} do test "group member list updates automatically after remove", %{conn: conn} do
@ -89,15 +98,19 @@ defmodule MvWeb.GroupLive.ShowRemoveMemberTest do
actor: system_actor actor: system_actor
) )
{:ok, view, _html} = live(conn, "/groups/#{group.slug}") {:ok, view, html} = live(conn, "/groups/#{group.slug}")
assert has_element?(view, "[data-testid=group-show-members-table]", "Charlie") # Member should be in list initially
assert html =~ "Charlie"
# Remove member
view view
|> element("[data-testid=group-show-remove-member][phx-value-member_id='#{member.id}']") |> element("button[phx-click='remove_member'][phx-value-member_id='#{member.id}']")
|> render_click() |> render_click()
refute has_element?(view, "[data-testid=group-show-members-table]", "Charlie") # Member should no longer be in list
html = render(view)
refute html =~ "Charlie"
end end
test "member count updates automatically after remove", %{conn: conn} do test "member count updates automatically after remove", %{conn: conn} do
@ -145,7 +158,7 @@ defmodule MvWeb.GroupLive.ShowRemoveMemberTest do
# Extract first member ID from the rendered HTML or use a different approach # Extract first member ID from the rendered HTML or use a different approach
# Since we have member1 and member2, we can target member1 specifically # Since we have member1 and member2, we can target member1 specifically
view view
|> element("[data-testid=group-show-remove-member][phx-value-member_id='#{member1.id}']") |> element("button[phx-click='remove_member'][phx-value-member_id='#{member1.id}']")
|> render_click() |> render_click()
# Count should have decreased # Count should have decreased
@ -174,11 +187,17 @@ defmodule MvWeb.GroupLive.ShowRemoveMemberTest do
{:ok, view, _html} = live(conn, "/groups/#{group.slug}") {:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Click Remove - should remove immediately without confirmation
view view
|> element("[data-testid=group-show-remove-member][phx-value-member_id='#{member.id}']") |> element("button[phx-click='remove_member'][phx-value-member_id='#{member.id}']")
|> render_click() |> render_click()
refute has_element?(view, "[data-testid=group-show-members-table]", "Frank") # No confirmation dialog should appear (immediate removal)
# This is verified by the member being removed without any dialog
# Member should be removed
html = render(view)
refute html =~ "Frank"
end end
end end
@ -201,17 +220,23 @@ defmodule MvWeb.GroupLive.ShowRemoveMemberTest do
actor: system_actor actor: system_actor
) )
{:ok, view, _html} = live(conn, "/groups/#{group.slug}") {:ok, view, html} = live(conn, "/groups/#{group.slug}")
assert has_element?(view, "[data-testid=group-show-members-table]", "Grace") # Member should be in list
assert html =~ "Grace"
# Remove last member
view view
|> element("[data-testid=group-show-remove-member][phx-value-member_id='#{member.id}']") |> element("button[phx-click='remove_member'][phx-value-member_id='#{member.id}']")
|> render_click() |> render_click()
assert has_element?(view, "[data-testid=group-show-no-members]") # Group should show empty state
html = render(view) html = render(view)
assert html =~ gettext("No members in this group") ||
html =~ ~r/no.*members/i
# Count should be 0
count = extract_member_count(html) count = extract_member_count(html)
assert count == 0 assert count == 0
end end
@ -244,14 +269,18 @@ defmodule MvWeb.GroupLive.ShowRemoveMemberTest do
{:ok, view, _html} = live(conn, "/groups/#{group1.slug}") {:ok, view, _html} = live(conn, "/groups/#{group1.slug}")
# Remove from group1
view view
|> element("[data-testid=group-show-remove-member][phx-value-member_id='#{member.id}']") |> element("button[phx-click='remove_member'][phx-value-member_id='#{member.id}']")
|> render_click() |> render_click()
refute has_element?(view, "[data-testid=group-show-members-table]", "Henry") # Member should be removed from group1
html = render(view)
refute html =~ "Henry"
{:ok, view2, _html2} = live(conn, "/groups/#{group2.slug}") # Verify member is still in group2
assert has_element?(view2, "[data-testid=group-show-members-table]", "Henry") {:ok, _view2, html2} = live(conn, "/groups/#{group2.slug}")
assert html2 =~ "Henry"
end end
test "remove is idempotent (no error if member already removed)", %{conn: conn} do test "remove is idempotent (no error if member already removed)", %{conn: conn} do
@ -274,15 +303,22 @@ defmodule MvWeb.GroupLive.ShowRemoveMemberTest do
{:ok, view, _html} = live(conn, "/groups/#{group.slug}") {:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Remove member first time
view view
|> element("[data-testid=group-show-remove-member][phx-value-member_id='#{member.id}']") |> element("button[phx-click='remove_member'][phx-value-member_id='#{member.id}']")
|> render_click() |> render_click()
if has_element?(view, "[data-testid=group-show-members-table]", "Isabel") do # Try to remove again (should not error, just be idempotent)
# Note: Implementation should handle this gracefully
# If button is still visible somehow, try to click again
html = render(view)
if html =~ "Isabel" do
view view
|> element("[data-testid=group-show-remove-member][phx-value-member_id='#{member.id}']") |> element("button[phx-click='remove_member'][phx-value-member_id='#{member.id}']")
|> render_click() |> render_click()
# Should not crash
assert render(view) assert render(view)
end end
end end

View file

@ -22,33 +22,34 @@ defmodule MvWeb.GroupLive.ShowTest do
test "page renders successfully", %{conn: conn} do test "page renders successfully", %{conn: conn} do
group = Fixtures.group_fixture() group = Fixtures.group_fixture()
{:ok, view, _html} = live(conn, "/groups/#{group.slug}") {:ok, _view, html} = live(conn, "/groups/#{group.slug}")
assert has_element?(view, "h1", group.name) assert html =~ group.name
end end
test "displays group name", %{conn: conn} do test "displays group name", %{conn: conn} do
group = Fixtures.group_fixture(%{name: "Test Group Name"}) group = Fixtures.group_fixture(%{name: "Test Group Name"})
{:ok, view, _html} = live(conn, "/groups/#{group.slug}") {:ok, _view, html} = live(conn, "/groups/#{group.slug}")
assert has_element?(view, "h1", "Test Group Name") assert html =~ "Test Group Name"
end end
test "displays group description when present", %{conn: conn} do test "displays group description when present", %{conn: conn} do
group = Fixtures.group_fixture(%{description: "This is a test description"}) group = Fixtures.group_fixture(%{description: "This is a test description"})
{:ok, view, _html} = live(conn, "/groups/#{group.slug}") {:ok, _view, html} = live(conn, "/groups/#{group.slug}")
assert has_element?(view, "p", "This is a test description") assert html =~ "This is a test description"
end end
test "displays member count", %{conn: conn} do test "displays member count", %{conn: conn} do
group = Fixtures.group_fixture() group = Fixtures.group_fixture()
{:ok, view, _html} = live(conn, "/groups/#{group.slug}") {:ok, _view, html} = live(conn, "/groups/#{group.slug}")
assert has_element?(view, "[data-testid=group-show-member-count]") # Member count should be displayed (might be 0 or more)
assert html =~ "0" or html =~ gettext("Members") or html =~ "member" or html =~ "Mitglied"
end end
test "displays list of members in group", %{conn: conn} do test "displays list of members in group", %{conn: conn} do
@ -66,26 +67,26 @@ defmodule MvWeb.GroupLive.ShowTest do
actor: system_actor actor: system_actor
) )
{:ok, view, _html} = live(conn, "/groups/#{group.slug}") {:ok, _view, html} = live(conn, "/groups/#{group.slug}")
assert has_element?(view, "[data-testid=group-show-members-table]", "Alice") assert html =~ "Alice" or html =~ "Smith"
assert has_element?(view, "[data-testid=group-show-members-table]", "Bob") assert html =~ "Bob" or html =~ "Jones"
end end
test "displays edit button for admin users", %{conn: conn} do test "displays edit button for admin users", %{conn: conn} do
group = Fixtures.group_fixture() group = Fixtures.group_fixture()
{:ok, view, _html} = live(conn, "/groups/#{group.slug}") {:ok, _view, html} = live(conn, "/groups/#{group.slug}")
assert has_element?(view, "[data-testid=group-show-edit-btn]") assert html =~ gettext("Edit") or html =~ "edit" or html =~ "Bearbeiten"
end end
test "displays delete button for admin users", %{conn: conn} do test "displays delete button for admin users", %{conn: conn} do
group = Fixtures.group_fixture() group = Fixtures.group_fixture()
{:ok, view, _html} = live(conn, "/groups/#{group.slug}") {:ok, _view, html} = live(conn, "/groups/#{group.slug}")
assert has_element?(view, "[data-testid=group-show-delete-btn]") assert html =~ gettext("Delete") or html =~ "delete" or html =~ "Löschen"
end end
end end
@ -93,17 +94,19 @@ defmodule MvWeb.GroupLive.ShowTest do
test "route /groups/:slug works correctly", %{conn: conn} do test "route /groups/:slug works correctly", %{conn: conn} do
group = Fixtures.group_fixture(%{name: "Board Members"}) group = Fixtures.group_fixture(%{name: "Board Members"})
{:ok, view, _html} = live(conn, "/groups/#{group.slug}") {:ok, _view, html} = live(conn, "/groups/#{group.slug}")
assert has_element?(view, "h1", "Board Members") assert html =~ "Board Members"
# Verify slug is in URL
assert html =~ group.slug or html =~ "board-members"
end end
test "group is found by slug via unique_slug identity", %{conn: conn} do test "group is found by slug via unique_slug identity", %{conn: conn} do
group = Fixtures.group_fixture(%{name: "Test Group"}) group = Fixtures.group_fixture(%{name: "Test Group"})
{:ok, view, _html} = live(conn, "/groups/#{group.slug}") {:ok, _view, html} = live(conn, "/groups/#{group.slug}")
assert has_element?(view, "h1", group.name) assert html =~ group.name
end end
test "non-existent slug returns 404", %{conn: conn} do test "non-existent slug returns 404", %{conn: conn} do
@ -142,26 +145,28 @@ defmodule MvWeb.GroupLive.ShowTest do
test "displays empty group correctly (0 members)", %{conn: conn} do test "displays empty group correctly (0 members)", %{conn: conn} do
group = Fixtures.group_fixture() group = Fixtures.group_fixture()
{:ok, view, _html} = live(conn, "/groups/#{group.slug}") {:ok, _view, html} = live(conn, "/groups/#{group.slug}")
assert has_element?(view, "[data-testid=group-show-no-members]") assert html =~ "0" or html =~ gettext("No members") or html =~ "empty" or
html =~ "Keine Mitglieder"
end end
test "handles group without description correctly", %{conn: conn} do test "handles group without description correctly", %{conn: conn} do
group = Fixtures.group_fixture(%{description: nil}) group = Fixtures.group_fixture(%{description: nil})
{:ok, view, _html} = live(conn, "/groups/#{group.slug}") {:ok, _view, html} = live(conn, "/groups/#{group.slug}")
assert has_element?(view, "h1", group.name) # Should not crash, description should be optional
assert html =~ group.name
end end
test "handles slug with special characters correctly", %{conn: conn} do test "handles slug with special characters correctly", %{conn: conn} do
# Create group with name that generates slug with hyphens # Create group with name that generates slug with hyphens
group = Fixtures.group_fixture(%{name: "Test-Group-Name"}) group = Fixtures.group_fixture(%{name: "Test-Group-Name"})
{:ok, view, _html} = live(conn, "/groups/#{group.slug}") {:ok, _view, html} = live(conn, "/groups/#{group.slug}")
assert has_element?(view, "h1", group.name) assert html =~ "Test-Group-Name" or html =~ group.name
end end
end end
@ -172,11 +177,11 @@ defmodule MvWeb.GroupLive.ShowTest do
read_only_user = Fixtures.user_with_role_fixture("read_only") read_only_user = Fixtures.user_with_role_fixture("read_only")
conn = conn_with_password_user(conn, read_only_user) conn = conn_with_password_user(conn, read_only_user)
{:ok, view, _html} = live(conn, "/groups/#{group.slug}") {:ok, _view, html} = live(conn, "/groups/#{group.slug}")
assert has_element?(view, "h1", group.name) assert html =~ group.name
refute has_element?(view, "[data-testid=group-show-edit-btn]") # Should NOT see edit/delete buttons
refute has_element?(view, "[data-testid=group-show-delete-btn]") refute html =~ gettext("Edit") or html =~ gettext("Delete")
end end
@tag role: :unauthenticated @tag role: :unauthenticated
@ -241,14 +246,14 @@ defmodule MvWeb.GroupLive.ShowTest do
handler_id = "test-query-counter-#{System.unique_integer([:positive])}" handler_id = "test-query-counter-#{System.unique_integer([:positive])}"
:telemetry.attach(handler_id, [:ash, :query, :start], handler, nil) :telemetry.attach(handler_id, [:ash, :query, :start], handler, nil)
{:ok, view, _html} = live(conn, "/groups/#{group.slug}") {:ok, _view, html} = live(conn, "/groups/#{group.slug}")
final_count = Agent.get(query_count_agent, & &1) final_count = Agent.get(query_count_agent, & &1)
:telemetry.detach(handler_id) :telemetry.detach(handler_id)
# All members should be displayed
Enum.each(members, fn member -> Enum.each(members, fn member ->
assert has_element?(view, "[data-testid=group-show-members-table]", member.first_name) or assert html =~ member.first_name or html =~ member.last_name
has_element?(view, "[data-testid=group-show-members-table]", member.last_name)
end) end)
# Verify query count is reasonable (should avoid N+1 queries) # Verify query count is reasonable (should avoid N+1 queries)
@ -262,9 +267,10 @@ defmodule MvWeb.GroupLive.ShowTest do
test "slug lookup is efficient (uses unique_slug index)", %{conn: conn} do test "slug lookup is efficient (uses unique_slug index)", %{conn: conn} do
group = Fixtures.group_fixture() group = Fixtures.group_fixture()
{:ok, view, _html} = live(conn, "/groups/#{group.slug}") # Should use index for fast lookup
{:ok, _view, html} = live(conn, "/groups/#{group.slug}")
assert has_element?(view, "h1", group.name) assert html =~ group.name
end end
end end

View file

@ -1,5 +1,5 @@
defmodule MvWeb.MemberLive.IndexTest do defmodule MvWeb.MemberLive.IndexTest do
use MvWeb.ConnCase, async: false use MvWeb.ConnCase, async: true
import Phoenix.LiveViewTest import Phoenix.LiveViewTest
require Ash.Query require Ash.Query

View file

@ -13,7 +13,7 @@ defmodule MvWeb.GroupLiveHelpers do
""" """
def open_add_member(view) do def open_add_member(view) do
view view
|> element("button[phx-click='show_add_member_input']") |> element("button", "Add Member")
|> render_click() |> render_click()
end end
@ -22,7 +22,7 @@ defmodule MvWeb.GroupLiveHelpers do
""" """
def search_member(view, query) do def search_member(view, query) do
view view
|> element("[data-testid=group-show-member-search-input]") |> element("#member-search-input")
|> render_focus() |> render_focus()
view view
@ -44,7 +44,7 @@ defmodule MvWeb.GroupLiveHelpers do
""" """
def add_selected(view) do def add_selected(view) do
view view
|> element("[data-testid=group-show-add-selected-members-btn]") |> element("button[phx-click='add_selected_members']")
|> render_click() |> render_click()
end end