Compare commits

..

22 commits

Author SHA1 Message Date
342ebb73b9
refactor: reduce nesting depth in process_batch function
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-15 21:26:35 +01:00
c3ccecd138
fix: address notification handling review feedback
1. Fix misleading comment in async create_member path
2. Use skip_lock?: true in test case for create_member
3. Fix generate_cycles_for_all_members/1
2025-12-15 16:23:47 +01:00
666c7b8802
refactor: implement proper notification handling via after_action hooks
All checks were successful
continuous-integration/drone/push Build is passing
Refactor notification handling according to Ash best practices
2025-12-15 16:01:31 +01:00
bb5851bc23
fix: resolve notification handling and maintain after_action for cycle regeneration
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-15 15:26:39 +01:00
65d1561803
fix: correct return_notifications? logic to prevent missed notifications
All checks were successful
continuous-integration/drone/push Build is passing
Fix the logic for return_notifications? in create_cycles
2025-12-15 13:35:59 +01:00
3fad912025
refactor: reduce nesting depth in regenerate_cycles_on_type_change
Split the function into smaller, focused functions to reduce nesting depth
2025-12-15 13:35:48 +01:00
2f83f35bcc
fix: address code review points for cycle regeneration
1. Fix critical notifications bug
2. Fix today inconsistency
3. Add advisory lock around deletion
4. Improve helper function documentation
5. Improve error message UX
2025-12-15 13:35:42 +01:00
15fc897f2a
refactor: reduce complexity of with_advisory_lock function
Split the complex with_advisory_lock function into smaller, focused
functions to improve readability and reduce cyclomatic complexity
2025-12-15 13:35:28 +01:00
b81ed99571
fix: prevent deadlocks by detecting existing transactions 2025-12-15 12:41:00 +01:00
ba6b81e155
test: update test to reflect nil assignment prevention 2025-12-15 12:40:40 +01:00
446f75bcc1
fix: remove unused variable warning in ValidateSameInterval 2025-12-15 12:40:31 +01:00
bbae62313d
docs: update architecture docs for atomic cycle regeneration 2025-12-15 12:40:22 +01:00
9f33db3fed
test: add monthly interval tests for cycle calculations 2025-12-15 12:40:06 +01:00
f08b520e66
test: remove Process.sleep from type change integration tests 2025-12-15 12:39:51 +01:00
28b2be1125
fix: prevent nil assignment for membership_fee_type_id
Reject attempts to set membership_fee_type_id to nil when a current type
exists.
2025-12-15 12:39:29 +01:00
032db2a4ba
fix: implement fail-closed behavior in ValidateSameInterval
Change validation to fail closed instead of fail open when types cannot
be loaded. This prevents inconsistent data states and provides clearer
error messages to users.
2025-12-15 12:39:08 +01:00
1e5f84fd88
fix: make cycle regeneration atomic on type change
Make cycle regeneration synchronous in the same transaction as the member
update to ensure atomicity.
2025-12-15 12:38:40 +01:00
e9c53cc520 refactor: reduce nesting depth and improve code readability
All checks were successful
continuous-integration/drone/push Build is passing
- Replace Enum.map |> Enum.join with Enum.map_join for efficiency
- Extract helper functions to reduce nesting depth from 4 to 2
- Rename is_current_cycle? to current_cycle? following Elixir conventions
2025-12-15 11:50:08 +01:00
06324d77c5
feat: regenerate cycles when membership fee type changes (same interval)
Some checks failed
continuous-integration/drone/push Build is failing
- Implemented regenerate_cycles_on_type_change helper in Member resource
- Cycles that haven't ended yet (cycle_end >= today) are deleted and regenerated
- Paid and suspended cycles remain unchanged (not deleted)
- CycleGenerator reloads member with new membership_fee_type_id
- Adjusted tests to work with current cycles only (no future cycles)
- All integration tests passing

Phase 4 completed: Cycle regeneration on type change
2025-12-15 11:39:26 +01:00
7994303166
feat: add validation for same-interval membership fee type changes 2025-12-15 11:35:48 +01:00
6763d4f2eb
feat: add cycle status calculations to Member resource 2025-12-15 11:35:47 +01:00
48d98b97b2
feat: add status management actions to MembershipFeeCycle 2025-12-15 11:35:33 +01:00
18 changed files with 153 additions and 275 deletions

View file

@ -12,6 +12,7 @@ defmodule Mv.Membership.CustomField do
- `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`) - `value_type` - Data type constraint (`:string`, `:integer`, `:boolean`, `:date`, `:email`)
- `description` - Optional human-readable description - `description` - Optional human-readable description
- `immutable` - If true, custom field values cannot be changed after creation
- `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
@ -59,10 +60,10 @@ defmodule Mv.Membership.CustomField do
actions do actions do
defaults [:read, :update] defaults [:read, :update]
default_accept [:name, :value_type, :description, :required, :show_in_overview] default_accept [:name, :value_type, :description, :immutable, :required, :show_in_overview]
create :create do create :create do
accept [:name, :value_type, :description, :required, :show_in_overview] accept [:name, :value_type, :description, :immutable, :required, :show_in_overview]
change Mv.Membership.CustomField.Changes.GenerateSlug change Mv.Membership.CustomField.Changes.GenerateSlug
validate string_length(:slug, min: 1) validate string_length(:slug, min: 1)
end end
@ -112,6 +113,10 @@ defmodule Mv.Membership.CustomField do
trim?: true trim?: true
] ]
attribute :immutable, :boolean,
default: false,
allow_nil?: false
attribute :required, :boolean, attribute :required, :boolean,
default: false, default: false,
allow_nil?: false allow_nil?: false

View file

@ -95,11 +95,9 @@ defmodule MvWeb.CoreComponents do
<.button>Send!</.button> <.button>Send!</.button>
<.button phx-click="go" variant="primary">Send!</.button> <.button phx-click="go" variant="primary">Send!</.button>
<.button navigate={~p"/"}>Home</.button> <.button navigate={~p"/"}>Home</.button>
<.button disabled={true}>Disabled</.button>
""" """
attr :rest, :global, include: ~w(href navigate patch method) attr :rest, :global, include: ~w(href navigate patch method)
attr :variant, :string, values: ~w(primary) attr :variant, :string, values: ~w(primary)
attr :disabled, :boolean, default: false, doc: "Whether the button is disabled"
slot :inner_block, required: true slot :inner_block, required: true
def button(%{rest: rest} = assigns) do def button(%{rest: rest} = assigns) do
@ -107,37 +105,14 @@ defmodule MvWeb.CoreComponents do
assigns = assign(assigns, :class, Map.fetch!(variants, assigns[:variant])) assigns = assign(assigns, :class, Map.fetch!(variants, assigns[:variant]))
if rest[:href] || rest[:navigate] || rest[:patch] do if rest[:href] || rest[:navigate] || rest[:patch] do
# For links, we can't use disabled attribute, so we use btn-disabled class
# DaisyUI's btn-disabled provides the same styling as :disabled on buttons
link_class =
if assigns[:disabled],
do: ["btn", assigns.class, "btn-disabled"],
else: ["btn", assigns.class]
# Prevent interaction when disabled
# Remove navigation attributes to prevent "Open in new tab", "Copy link" etc.
link_attrs =
if assigns[:disabled] do
rest
|> Map.drop([:href, :navigate, :patch])
|> Map.merge(%{tabindex: "-1", "aria-disabled": "true"})
else
rest
end
assigns =
assigns
|> assign(:link_class, link_class)
|> assign(:link_attrs, link_attrs)
~H""" ~H"""
<.link class={@link_class} {@link_attrs}> <.link class={["btn", @class]} {@rest}>
{render_slot(@inner_block)} {render_slot(@inner_block)}
</.link> </.link>
""" """
else else
~H""" ~H"""
<button class={["btn", @class]} disabled={@disabled} {@rest}> <button class={["btn", @class]} {@rest}>
{render_slot(@inner_block)} {render_slot(@inner_block)}
</button> </button>
""" """

View file

@ -36,16 +36,12 @@ defmodule MvWeb.Layouts do
default: nil, default: nil,
doc: "the current [scope](https://hexdocs.pm/phoenix/scopes.html)" doc: "the current [scope](https://hexdocs.pm/phoenix/scopes.html)"
attr :club_name, :string,
default: nil,
doc: "optional club name to pass to navbar"
slot :inner_block, required: true slot :inner_block, required: true
def app(assigns) do def app(assigns) do
~H""" ~H"""
<%= if @current_user do %> <%= if @current_user do %>
<.navbar current_user={@current_user} club_name={@club_name} /> <.navbar current_user={@current_user} />
<% end %> <% end %>
<main class="px-4 py-20 sm:px-6 lg:px-16"> <main class="px-4 py-20 sm:px-6 lg:px-16">
<div class="mx-auto max-full space-y-4"> <div class="mx-auto max-full space-y-4">

View file

@ -12,18 +12,15 @@ defmodule MvWeb.Layouts.Navbar do
required: true, required: true,
doc: "The current user - navbar is only shown when user is present" doc: "The current user - navbar is only shown when user is present"
attr :club_name, :string,
default: nil,
doc: "Optional club name - if not provided, will be loaded from database"
def navbar(assigns) do def navbar(assigns) do
club_name = assigns[:club_name] || get_club_name() club_name = get_club_name()
assigns = assign(assigns, :club_name, club_name) assigns = assign(assigns, :club_name, club_name)
~H""" ~H"""
<header class="navbar bg-base-100 shadow-sm"> <header class="navbar bg-base-100 shadow-sm">
<div class="flex-1"> <div class="flex-1">
<a href="/members" class="btn btn-ghost text-xl">{@club_name}</a> <a class="btn btn-ghost text-xl">{@club_name}</a>
<ul class="menu menu-horizontal bg-base-200"> <ul class="menu menu-horizontal bg-base-200">
<li><.link navigate="/members">{gettext("Members")}</.link></li> <li><.link navigate="/members">{gettext("Members")}</.link></li>
<li><.link navigate="/settings">{gettext("Settings")}</.link></li> <li><.link navigate="/settings">{gettext("Settings")}</.link></li>

View file

@ -77,7 +77,7 @@ defmodule MvWeb.Components.PaymentFilterComponent do
phx-target={@myself} phx-target={@myself}
> >
<.icon name="hero-users" class="h-4 w-4" /> <.icon name="hero-users" class="h-4 w-4" />
{gettext("All payment statuses")} {gettext("All")}
</button> </button>
</li> </li>
<li role="none"> <li role="none">
@ -140,7 +140,7 @@ defmodule MvWeb.Components.PaymentFilterComponent do
defp parse_filter(_), do: nil defp parse_filter(_), do: nil
# Get display label for current filter # Get display label for current filter
defp filter_label(nil), do: gettext("All payment statuses") defp filter_label(nil), do: gettext("All")
defp filter_label(:paid), do: gettext("Paid") defp filter_label(:paid), do: gettext("Paid")
defp filter_label(:not_paid), do: gettext("Not paid") defp filter_label(:not_paid), do: gettext("Not paid")
end end

View file

@ -6,7 +6,7 @@ defmodule MvWeb.CustomFieldLive.FormComponent do
- Create new custom field definitions - Create new custom field definitions
- Edit existing custom fields - Edit existing custom fields
- Select value type from supported types - Select value type from supported types
- Set required flag - Set immutable and required flags
- Real-time validation - Real-time validation
## Props ## Props
@ -50,10 +50,10 @@ defmodule MvWeb.CustomFieldLive.FormComponent do
label={gettext("Value type")} label={gettext("Value type")}
options={ options={
Ash.Resource.Info.attribute(Mv.Membership.CustomField, :value_type).constraints[:one_of] Ash.Resource.Info.attribute(Mv.Membership.CustomField, :value_type).constraints[:one_of]
|> Enum.map(fn type -> {MvWeb.Translations.FieldTypes.label(type), type} end)
} }
/> />
<.input field={@form[:description]} type="text" label={gettext("Description")} /> <.input field={@form[:description]} type="text" label={gettext("Description")} />
<.input field={@form[:immutable]} type="checkbox" label={gettext("Immutable")} />
<.input field={@form[:required]} type="checkbox" label={gettext("Required")} /> <.input field={@form[:required]} type="checkbox" label={gettext("Required")} />
<.input <.input
field={@form[:show_in_overview]} field={@form[:show_in_overview]}
@ -66,7 +66,7 @@ defmodule MvWeb.CustomFieldLive.FormComponent do
{gettext("Cancel")} {gettext("Cancel")}
</.button> </.button>
<.button phx-disable-with={gettext("Saving...")} variant="primary"> <.button phx-disable-with={gettext("Saving...")} variant="primary">
{gettext("Save Custom Field")} {gettext("Save Custom field")}
</.button> </.button>
</div> </div>
</.form> </.form>

View file

@ -5,7 +5,7 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
## Features ## Features
- List all custom fields - List all custom fields
- Display type information (name, value type, description) - Display type information (name, value type, description)
- Show required flag - Show immutable and required flags
- Create new custom fields - Create new custom fields
- Edit existing custom fields - Edit existing custom fields
- Delete custom fields with confirmation (cascades to all custom field values) - Delete custom fields with confirmation (cascades to all custom field values)
@ -30,7 +30,7 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
phx-click="new_custom_field" phx-click="new_custom_field"
phx-target={@myself} phx-target={@myself}
> >
<.icon name="hero-plus" /> {gettext("New Custom Field")} <.icon name="hero-plus" /> {gettext("New Custom field")}
</.button> </.button>
</div> </div>
</div> </div>

View file

@ -72,7 +72,7 @@ defmodule MvWeb.CustomFieldValueLive.Form do
<% end %> <% end %>
<.button phx-disable-with={gettext("Saving...")} variant="primary"> <.button phx-disable-with={gettext("Saving...")} variant="primary">
{gettext("Save Custom Field Value")} {gettext("Save Custom field value")}
</.button> </.button>
<.button navigate={return_path(@return_to, @custom_field_value)}>{gettext("Cancel")}</.button> <.button navigate={return_path(@return_to, @custom_field_value)}>{gettext("Cancel")}</.button>
</.form> </.form>

View file

@ -37,7 +37,7 @@ defmodule MvWeb.GlobalSettingsLive do
@impl true @impl true
def render(assigns) do def render(assigns) do
~H""" ~H"""
<Layouts.app flash={@flash} current_user={@current_user} club_name={@settings.club_name}> <Layouts.app flash={@flash} current_user={@current_user}>
<.header> <.header>
{gettext("Settings")} {gettext("Settings")}
<:subtitle> <:subtitle>
@ -80,13 +80,10 @@ defmodule MvWeb.GlobalSettingsLive do
@impl true @impl true
def handle_event("save", %{"setting" => setting_params}, socket) do def handle_event("save", %{"setting" => setting_params}, socket) do
case AshPhoenix.Form.submit(socket.assigns.form, params: setting_params) do case AshPhoenix.Form.submit(socket.assigns.form, params: setting_params) do
{:ok, _updated_settings} -> {:ok, updated_settings} ->
# Reload settings from database to ensure all dependent data is updated
{:ok, fresh_settings} = Membership.get_settings()
socket = socket =
socket socket
|> assign(:settings, fresh_settings) |> assign(:settings, updated_settings)
|> put_flash(:info, gettext("Settings updated successfully")) |> put_flash(:info, gettext("Settings updated successfully"))
|> assign_form() |> assign_form()

View file

@ -145,10 +145,7 @@ defmodule MvWeb.MemberLive.Index do
MapSet.put(socket.assigns.selected_members, id) MapSet.put(socket.assigns.selected_members, id)
end end
{:noreply, {:noreply, assign(socket, :selected_members, selected)}
socket
|> assign(:selected_members, selected)
|> update_selection_assigns()}
end end
@impl true @impl true
@ -162,10 +159,7 @@ defmodule MvWeb.MemberLive.Index do
all_ids all_ids
end end
{:noreply, {:noreply, assign(socket, :selected_members, selected)}
socket
|> assign(:selected_members, selected)
|> update_selection_assigns()}
end end
@impl true @impl true
@ -244,7 +238,6 @@ defmodule MvWeb.MemberLive.Index do
socket socket
|> assign(:query, q) |> assign(:query, q)
|> load_members() |> load_members()
|> update_selection_assigns()
existing_field_query = socket.assigns.sort_field existing_field_query = socket.assigns.sort_field
existing_sort_query = socket.assigns.sort_order existing_sort_query = socket.assigns.sort_order
@ -270,7 +263,6 @@ defmodule MvWeb.MemberLive.Index do
socket socket
|> assign(:paid_filter, filter) |> assign(:paid_filter, filter)
|> load_members() |> load_members()
|> update_selection_assigns()
# Build the URL with all params including new filter # Build the URL with all params including new filter
query_params = query_params =
@ -317,7 +309,6 @@ defmodule MvWeb.MemberLive.Index do
|> assign(:visible_custom_field_ids, extract_custom_field_ids(visible_custom_fields)) |> assign(:visible_custom_field_ids, extract_custom_field_ids(visible_custom_fields))
|> load_members() |> load_members()
|> prepare_dynamic_cols() |> prepare_dynamic_cols()
|> update_selection_assigns()
|> push_field_selection_url() |> push_field_selection_url()
{:noreply, socket} {:noreply, socket}
@ -347,7 +338,6 @@ defmodule MvWeb.MemberLive.Index do
|> assign(:visible_custom_field_ids, extract_custom_field_ids(visible_custom_fields)) |> assign(:visible_custom_field_ids, extract_custom_field_ids(visible_custom_fields))
|> load_members() |> load_members()
|> prepare_dynamic_cols() |> prepare_dynamic_cols()
|> update_selection_assigns()
|> push_field_selection_url() |> push_field_selection_url()
{:noreply, socket} {:noreply, socket}
@ -399,7 +389,6 @@ defmodule MvWeb.MemberLive.Index do
|> assign(:visible_custom_field_ids, extract_custom_field_ids(visible_custom_fields)) |> assign(:visible_custom_field_ids, extract_custom_field_ids(visible_custom_fields))
|> load_members() |> load_members()
|> prepare_dynamic_cols() |> prepare_dynamic_cols()
|> update_selection_assigns()
{:noreply, socket} {:noreply, socket}
end end
@ -1123,34 +1112,4 @@ defmodule MvWeb.MemberLive.Index do
# Public helper function to format dates for use in templates # Public helper function to format dates for use in templates
def format_date(date), do: DateFormatter.format_date(date) def format_date(date), do: DateFormatter.format_date(date)
# Updates selection-related assigns (selected_count, any_selected?, mailto_bcc)
# to avoid recalculating Enum.any? and Enum.count multiple times in templates.
#
# Note: Mailto URLs have length limits that vary by email client.
# For large selections, consider using export functionality instead.
defp update_selection_assigns(socket) do
members = socket.assigns.members
selected_members = socket.assigns.selected_members
selected_count =
Enum.count(members, &MapSet.member?(selected_members, &1.id))
any_selected? =
Enum.any?(members, &MapSet.member?(selected_members, &1.id))
mailto_bcc =
if any_selected? do
format_selected_member_emails(members, selected_members)
|> Enum.join(", ")
|> URI.encode_www_form()
else
""
end
socket
|> assign(:selected_count, selected_count)
|> assign(:any_selected?, any_selected?)
|> assign(:mailto_bcc, mailto_bcc)
end
end end

View file

@ -3,21 +3,23 @@
{gettext("Members")} {gettext("Members")}
<:actions> <:actions>
<.button <.button
class="secondary" :if={Enum.any?(@members, &MapSet.member?(@selected_members, &1.id))}
id="copy-emails-btn" id="copy-emails-btn"
phx-hook="CopyToClipboard" phx-hook="CopyToClipboard"
phx-click="copy_emails" phx-click="copy_emails"
disabled={not @any_selected?}
aria-label={gettext("Copy email addresses of selected members")} aria-label={gettext("Copy email addresses of selected members")}
> >
<.icon name="hero-clipboard-document" /> <.icon name="hero-clipboard-document" />
{gettext("Copy email addresses")} ({@selected_count}) {gettext("Copy emails")} ({Enum.count(@members, &MapSet.member?(@selected_members, &1.id))})
</.button> </.button>
<.button <.button
class="secondary" :if={Enum.any?(@members, &MapSet.member?(@selected_members, &1.id))}
id="open-email-btn" href={
href={"mailto:?bcc=" <> @mailto_bcc} "mailto:?bcc=" <>
disabled={not @any_selected?} (MvWeb.MemberLive.Index.format_selected_member_emails(@members, @selected_members)
|> Enum.join(", ")
|> URI.encode())
}
aria-label={gettext("Open email program with BCC recipients")} aria-label={gettext("Open email program with BCC recipients")}
> >
<.icon name="hero-envelope" /> <.icon name="hero-envelope" />

View file

@ -282,6 +282,11 @@ msgstr "Benutzer*in bearbeiten"
msgid "Enabled" msgid "Enabled"
msgstr "Aktiviert" msgstr "Aktiviert"
#: lib/mv_web/live/custom_field_live/form_component.ex
#, elixir-autogen, elixir-format
msgid "Immutable"
msgstr "Unveränderlich"
#: lib/mv_web/components/layouts/navbar.ex #: lib/mv_web/components/layouts/navbar.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Logout" msgid "Logout"
@ -607,6 +612,16 @@ msgstr "Benutzerdefinierter Feldwert erfolgreich %{action}"
msgid "Please select a custom field first" msgid "Please select a custom field first"
msgstr "Bitte wähle zuerst ein Benutzerdefiniertes Feld" msgstr "Bitte wähle zuerst ein Benutzerdefiniertes Feld"
#: lib/mv_web/live/custom_field_live/form_component.ex
#, elixir-autogen, elixir-format
msgid "Save Custom field"
msgstr "Benutzerdefiniertes Feld speichern"
#: lib/mv_web/live/custom_field_value_live/form.ex
#, elixir-autogen, elixir-format
msgid "Save Custom field value"
msgstr "Benutzerdefinierten Feldwert speichern"
#: lib/mv_web/live/custom_field_live/index_component.ex #: lib/mv_web/live/custom_field_live/index_component.ex
#: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show.ex #: lib/mv_web/live/member_live/show.ex
@ -745,6 +760,11 @@ msgstr[1] "%{count} E-Mail-Adressen in die Zwischenablage kopiert"
msgid "Copy email addresses of selected members" msgid "Copy email addresses of selected members"
msgstr "E-Mail-Adressen der ausgewählten Mitglieder kopieren" msgstr "E-Mail-Adressen der ausgewählten Mitglieder kopieren"
#: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "Copy emails"
msgstr "E-Mails kopieren"
#: lib/mv_web/live/member_live/index.ex #: lib/mv_web/live/member_live/index.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "No email addresses found" msgid "No email addresses found"
@ -776,6 +796,7 @@ msgid "This field cannot be empty"
msgstr "Dieses Feld darf nicht leer bleiben" msgstr "Dieses Feld darf nicht leer bleiben"
#: lib/mv_web/components/core_components.ex #: lib/mv_web/components/core_components.ex
#: lib/mv_web/live/components/payment_filter_component.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "All" msgid "All"
msgstr "Alle" msgstr "Alle"
@ -1281,10 +1302,14 @@ msgid "Failed to delete custom field: %{error}"
msgstr "Konnte Feld nicht löschen: %{error}" msgstr "Konnte Feld nicht löschen: %{error}"
#: lib/mv_web/live/custom_field_live/form_component.ex #: lib/mv_web/live/custom_field_live/form_component.ex
#: lib/mv_web/live/custom_field_live/index_component.ex
#, elixir-autogen, elixir-format, fuzzy #, elixir-autogen, elixir-format, fuzzy
msgid "New Custom Field" msgid "New Custom Field"
msgstr "Neues Benutzerdefiniertes Feld" msgstr "Benutzerdefiniertes Feld speichern"
#: lib/mv_web/live/custom_field_live/index_component.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "New Custom field"
msgstr "Benutzerdefiniertes Feld speichern"
#: lib/mv_web/live/global_settings_live.ex #: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
@ -1402,31 +1427,6 @@ msgstr "Jährliches Intervall Beitrittszeitraum nicht einbezogen"
msgid "Yearly Interval - Joining Cycle Included" msgid "Yearly Interval - Joining Cycle Included"
msgstr "Jährliches Intervall Beitrittszeitraum einbezogen" msgstr "Jährliches Intervall Beitrittszeitraum einbezogen"
#: lib/mv_web/live/components/payment_filter_component.ex
#, elixir-autogen, elixir-format
msgid "All payment statuses"
msgstr "Jeder Zahlungs-Zustand"
#: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format, fuzzy
msgid "Copy email addresses"
msgstr "E-Mail-Adressen kopieren"
#: lib/mv_web/live/custom_field_live/form_component.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Save Custom Field"
msgstr "Benutzerdefiniertes Feld speichern"
#: lib/mv_web/live/custom_field_value_live/form.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Save Custom Field Value"
msgstr "Benutzerdefinierten Feldwert speichern"
#~ #: lib/mv_web/live/custom_field_live/show.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Auto-generated identifier (immutable)"
#~ msgstr "Automatisch generierter Bezeichner (unveränderlich)"
#~ #: lib/mv_web/live/contribution_settings_live.ex #~ #: lib/mv_web/live/contribution_settings_live.ex
#~ #, elixir-autogen, elixir-format #~ #, elixir-autogen, elixir-format
#~ msgid "Configure global settings for membership contributions." #~ msgid "Configure global settings for membership contributions."
@ -1443,17 +1443,6 @@ msgstr "Benutzerdefinierten Feldwert speichern"
#~ msgid "Contribution start" #~ msgid "Contribution start"
#~ msgstr "Beitragsbeginn" #~ msgstr "Beitragsbeginn"
#~ #: lib/mv_web/live/member_live/index.html.heex
#~ #, elixir-autogen, elixir-format
#~ msgid "Copy emails"
#~ msgstr "E-Mails kopieren"
#~ #: lib/mv_web/live/member_live/form.ex
#~ #: lib/mv_web/live/member_live/show.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Custom Field Values"
#~ msgstr "Benutzerdefinierte Feldwerte"
#~ #: lib/mv_web/live/contribution_settings_live.ex #~ #: lib/mv_web/live/contribution_settings_live.ex
#~ #, elixir-autogen, elixir-format #~ #, elixir-autogen, elixir-format
#~ msgid "Default Contribution Type" #~ msgid "Default Contribution Type"
@ -1474,11 +1463,6 @@ msgstr "Benutzerdefinierten Feldwert speichern"
#~ msgid "Generated periods" #~ msgid "Generated periods"
#~ msgstr "Generierte Zyklen" #~ msgstr "Generierte Zyklen"
#~ #: lib/mv_web/live/custom_field_live/form_component.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Immutable"
#~ msgstr "Unveränderlich"
#~ #: lib/mv_web/live/contribution_settings_live.ex #~ #: lib/mv_web/live/contribution_settings_live.ex
#~ #, elixir-autogen, elixir-format #~ #, elixir-autogen, elixir-format
#~ msgid "Include joining period" #~ msgid "Include joining period"
@ -1489,17 +1473,6 @@ msgstr "Benutzerdefinierten Feldwert speichern"
#~ msgid "Monthly Interval - Joining Period Included" #~ msgid "Monthly Interval - Joining Period Included"
#~ msgstr "Monatliches Intervall Beitrittszeitraum einbezogen" #~ msgstr "Monatliches Intervall Beitrittszeitraum einbezogen"
#~ #: lib/mv_web/live/custom_field_live/index_component.ex
#~ #, elixir-autogen, elixir-format, fuzzy
#~ msgid "New Custom field"
#~ msgstr "Benutzerdefiniertes Feld speichern"
#~ #: lib/mv_web/live/user_live/form.ex
#~ #: lib/mv_web/live/user_live/show.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Not set"
#~ msgstr "Nicht gesetzt"
#~ #: lib/mv_web/live/contribution_settings_live.ex #~ #: lib/mv_web/live/contribution_settings_live.ex
#~ #, elixir-autogen, elixir-format #~ #, elixir-autogen, elixir-format
#~ msgid "Quarterly Interval - Joining Period Excluded" #~ msgid "Quarterly Interval - Joining Period Excluded"

View file

@ -283,6 +283,11 @@ msgstr ""
msgid "Enabled" msgid "Enabled"
msgstr "" msgstr ""
#: lib/mv_web/live/custom_field_live/form_component.ex
#, elixir-autogen, elixir-format
msgid "Immutable"
msgstr ""
#: lib/mv_web/components/layouts/navbar.ex #: lib/mv_web/components/layouts/navbar.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Logout" msgid "Logout"
@ -608,6 +613,16 @@ msgstr ""
msgid "Please select a custom field first" msgid "Please select a custom field first"
msgstr "" msgstr ""
#: lib/mv_web/live/custom_field_live/form_component.ex
#, elixir-autogen, elixir-format
msgid "Save Custom field"
msgstr ""
#: lib/mv_web/live/custom_field_value_live/form.ex
#, elixir-autogen, elixir-format
msgid "Save Custom field value"
msgstr ""
#: lib/mv_web/live/custom_field_live/index_component.ex #: lib/mv_web/live/custom_field_live/index_component.ex
#: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show.ex #: lib/mv_web/live/member_live/show.ex
@ -746,6 +761,11 @@ msgstr[1] ""
msgid "Copy email addresses of selected members" msgid "Copy email addresses of selected members"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "Copy emails"
msgstr ""
#: lib/mv_web/live/member_live/index.ex #: lib/mv_web/live/member_live/index.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "No email addresses found" msgid "No email addresses found"
@ -777,6 +797,7 @@ msgid "This field cannot be empty"
msgstr "" msgstr ""
#: lib/mv_web/components/core_components.ex #: lib/mv_web/components/core_components.ex
#: lib/mv_web/live/components/payment_filter_component.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "All" msgid "All"
msgstr "" msgstr ""
@ -1282,11 +1303,15 @@ msgid "Failed to delete custom field: %{error}"
msgstr "" msgstr ""
#: lib/mv_web/live/custom_field_live/form_component.ex #: lib/mv_web/live/custom_field_live/form_component.ex
#: lib/mv_web/live/custom_field_live/index_component.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "New Custom Field" msgid "New Custom Field"
msgstr "" msgstr ""
#: lib/mv_web/live/custom_field_live/index_component.ex
#, elixir-autogen, elixir-format
msgid "New Custom field"
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 "Slug does not match. Deletion cancelled." msgid "Slug does not match. Deletion cancelled."
@ -1402,23 +1427,3 @@ msgstr ""
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Yearly Interval - Joining Cycle Included" msgid "Yearly Interval - Joining Cycle Included"
msgstr "" msgstr ""
#: lib/mv_web/live/components/payment_filter_component.ex
#, elixir-autogen, elixir-format
msgid "All payment statuses"
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "Copy email addresses"
msgstr ""
#: lib/mv_web/live/custom_field_live/form_component.ex
#, elixir-autogen, elixir-format
msgid "Save Custom Field"
msgstr ""
#: lib/mv_web/live/custom_field_value_live/form.ex
#, elixir-autogen, elixir-format
msgid "Save Custom Field Value"
msgstr ""

View file

@ -283,6 +283,11 @@ msgstr ""
msgid "Enabled" msgid "Enabled"
msgstr "" msgstr ""
#: lib/mv_web/live/custom_field_live/form_component.ex
#, elixir-autogen, elixir-format
msgid "Immutable"
msgstr ""
#: lib/mv_web/components/layouts/navbar.ex #: lib/mv_web/components/layouts/navbar.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Logout" msgid "Logout"
@ -608,6 +613,16 @@ msgstr ""
msgid "Please select a custom field first" msgid "Please select a custom field first"
msgstr "" msgstr ""
#: lib/mv_web/live/custom_field_live/form_component.ex
#, elixir-autogen, elixir-format
msgid "Save Custom field"
msgstr ""
#: lib/mv_web/live/custom_field_value_live/form.ex
#, elixir-autogen, elixir-format
msgid "Save Custom field value"
msgstr ""
#: lib/mv_web/live/custom_field_live/index_component.ex #: lib/mv_web/live/custom_field_live/index_component.ex
#: lib/mv_web/live/member_live/form.ex #: lib/mv_web/live/member_live/form.ex
#: lib/mv_web/live/member_live/show.ex #: lib/mv_web/live/member_live/show.ex
@ -746,6 +761,11 @@ msgstr[1] ""
msgid "Copy email addresses of selected members" msgid "Copy email addresses of selected members"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format
msgid "Copy emails"
msgstr ""
#: lib/mv_web/live/member_live/index.ex #: lib/mv_web/live/member_live/index.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "No email addresses found" msgid "No email addresses found"
@ -777,6 +797,7 @@ msgid "This field cannot be empty"
msgstr "" msgstr ""
#: lib/mv_web/components/core_components.ex #: lib/mv_web/components/core_components.ex
#: lib/mv_web/live/components/payment_filter_component.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "All" msgid "All"
msgstr "" msgstr ""
@ -1282,11 +1303,15 @@ msgid "Failed to delete custom field: %{error}"
msgstr "" msgstr ""
#: lib/mv_web/live/custom_field_live/form_component.ex #: lib/mv_web/live/custom_field_live/form_component.ex
#: lib/mv_web/live/custom_field_live/index_component.ex
#, elixir-autogen, elixir-format, fuzzy #, elixir-autogen, elixir-format, fuzzy
msgid "New Custom Field" msgid "New Custom Field"
msgstr "" msgstr ""
#: lib/mv_web/live/custom_field_live/index_component.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "New Custom field"
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 "Slug does not match. Deletion cancelled." msgid "Slug does not match. Deletion cancelled."
@ -1403,27 +1428,7 @@ msgstr ""
msgid "Yearly Interval - Joining Cycle Included" msgid "Yearly Interval - Joining Cycle Included"
msgstr "" msgstr ""
#: lib/mv_web/live/components/payment_filter_component.ex #~ #: lib/mv_web/live/contribution_settings_live.ex
#, elixir-autogen, elixir-format
msgid "All payment statuses"
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex
#, elixir-autogen, elixir-format, fuzzy
msgid "Copy email addresses"
msgstr ""
#: lib/mv_web/live/custom_field_live/form_component.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Save Custom Field"
msgstr ""
#: lib/mv_web/live/custom_field_value_live/form.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "Save Custom Field Value"
msgstr ""
#~ #: lib/mv_web/live/custom_field_live/show.ex
#~ #, elixir-autogen, elixir-format #~ #, elixir-autogen, elixir-format
#~ msgid "Configure global settings for membership contributions." #~ msgid "Configure global settings for membership contributions."
#~ msgstr "" #~ msgstr ""
@ -1434,18 +1439,11 @@ msgstr ""
#~ msgid "Contribution Settings" #~ msgid "Contribution Settings"
#~ msgstr "" #~ msgstr ""
#~ #: lib/mv_web/live/member_live/form.ex #~ #: lib/mv_web/live/contribution_settings_live.ex
#~ #: lib/mv_web/live/member_live/show.ex
#~ #, elixir-autogen, elixir-format #~ #, elixir-autogen, elixir-format
#~ msgid "Contribution start" #~ msgid "Contribution start"
#~ msgstr "" #~ msgstr ""
#~ #: lib/mv_web/live/contribution_settings_live.ex
#~ #: lib/mv_web/live/member_live/index.html.heex
#~ #, elixir-autogen, elixir-format
#~ msgid "Copy emails"
#~ msgstr ""
#~ #: lib/mv_web/live/contribution_settings_live.ex #~ #: lib/mv_web/live/contribution_settings_live.ex
#~ #, elixir-autogen, elixir-format #~ #, elixir-autogen, elixir-format
#~ msgid "Default Contribution Type" #~ msgid "Default Contribution Type"
@ -1461,18 +1459,11 @@ msgstr ""
#~ msgid "Failed to save settings. Please check the errors below." #~ msgid "Failed to save settings. Please check the errors below."
#~ msgstr "" #~ msgstr ""
#~ #: lib/mv_web/live/user_live/index.html.heex #~ #: lib/mv_web/live/contribution_settings_live.ex
#~ #: lib/mv_web/live/user_live/show.ex
#~ #, elixir-autogen, elixir-format #~ #, elixir-autogen, elixir-format
#~ msgid "Generated periods" #~ msgid "Generated periods"
#~ msgstr "" #~ msgstr ""
#~ #: lib/mv_web/live/contribution_settings_live.ex
#~ #: lib/mv_web/live/custom_field_live/form_component.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Immutable"
#~ msgstr ""
#~ #: lib/mv_web/live/contribution_settings_live.ex #~ #: lib/mv_web/live/contribution_settings_live.ex
#~ #, elixir-autogen, elixir-format #~ #, elixir-autogen, elixir-format
#~ msgid "Include joining period" #~ msgid "Include joining period"
@ -1483,16 +1474,6 @@ msgstr ""
#~ msgid "Monthly Interval - Joining Period Included" #~ msgid "Monthly Interval - Joining Period Included"
#~ msgstr "" #~ msgstr ""
#~ #: lib/mv_web/live/custom_field_live/index_component.ex
#~ #, elixir-autogen, elixir-format, fuzzy
#~ msgid "New Custom field"
#~ msgstr ""
#~ #: lib/mv_web/live/user_live/show.ex
#~ #, elixir-autogen, elixir-format, fuzzy
#~ msgid "Not set"
#~ msgstr ""
#~ #: lib/mv_web/live/contribution_settings_live.ex #~ #: lib/mv_web/live/contribution_settings_live.ex
#~ #, elixir-autogen, elixir-format #~ #, elixir-autogen, elixir-format
#~ msgid "Quarterly Interval - Joining Period Excluded" #~ msgid "Quarterly Interval - Joining Period Excluded"

View file

@ -1,21 +0,0 @@
defmodule Mv.Repo.Migrations.RemoveImmutableFromCustomFields do
@moduledoc """
Removes the immutable column from custom_fields table.
The immutable field is no longer needed in the custom field definition.
"""
use Ecto.Migration
def up do
alter table(:custom_fields) do
remove :immutable
end
end
def down do
alter table(:custom_fields) do
add :immutable, :boolean, null: false, default: false
end
end
end

View file

@ -45,24 +45,28 @@ for attrs <- [
name: "String Field", name: "String Field",
value_type: :string, value_type: :string,
description: "Example for a field of type string", description: "Example for a field of type string",
immutable: true,
required: false required: false
}, },
%{ %{
name: "Date Field", name: "Date Field",
value_type: :date, value_type: :date,
description: "Example for a field of type date", description: "Example for a field of type date",
immutable: true,
required: false required: false
}, },
%{ %{
name: "Boolean Field", name: "Boolean Field",
value_type: :boolean, value_type: :boolean,
description: "Example for a field of type boolean", description: "Example for a field of type boolean",
immutable: true,
required: false required: false
}, },
%{ %{
name: "Email Field", name: "Email Field",
value_type: :email, value_type: :email,
description: "Example for a field of type email", description: "Example for a field of type email",
immutable: true,
required: false required: false
}, },
# Realistic custom fields # Realistic custom fields
@ -70,48 +74,56 @@ for attrs <- [
name: "Membership Number", name: "Membership Number",
value_type: :string, value_type: :string,
description: "Unique membership identification number", description: "Unique membership identification number",
immutable: false,
required: false required: false
}, },
%{ %{
name: "Emergency Contact", name: "Emergency Contact",
value_type: :string, value_type: :string,
description: "Emergency contact person name and phone", description: "Emergency contact person name and phone",
immutable: false,
required: false required: false
}, },
%{ %{
name: "T-Shirt Size", name: "T-Shirt Size",
value_type: :string, value_type: :string,
description: "T-Shirt size for events (XS, S, M, L, XL, XXL)", description: "T-Shirt size for events (XS, S, M, L, XL, XXL)",
immutable: false,
required: false required: false
}, },
%{ %{
name: "Newsletter Subscription", name: "Newsletter Subscription",
value_type: :boolean, value_type: :boolean,
description: "Whether member wants to receive newsletter", description: "Whether member wants to receive newsletter",
immutable: false,
required: false required: false
}, },
%{ %{
name: "Date of Last Medical Check", name: "Date of Last Medical Check",
value_type: :date, value_type: :date,
description: "Date of last medical examination", description: "Date of last medical examination",
immutable: false,
required: false required: false
}, },
%{ %{
name: "Secondary Email", name: "Secondary Email",
value_type: :email, value_type: :email,
description: "Alternative email address", description: "Alternative email address",
immutable: false,
required: false required: false
}, },
%{ %{
name: "Membership Type", name: "Membership Type",
value_type: :string, value_type: :string,
description: "Type of membership (e.g., Regular, Student, Senior)", description: "Type of membership (e.g., Regular, Student, Senior)",
immutable: false,
required: false required: false
}, },
%{ %{
name: "Parking Permit", name: "Parking Permit",
value_type: :boolean, value_type: :boolean,
description: "Whether member has parking permit", description: "Whether member has parking permit",
immutable: false,
required: false required: false
} }
] do ] do

View file

@ -52,11 +52,14 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsAccessibilityTest do
field: field field: field
} do } 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")
# Check that the sort button has aria-label and data-testid # Check that the sort button has aria-label
test_id = "custom_field_#{field.id}" assert html =~ ~r/aria-label=["']Click to sort["']/i or
assert has_element?(view, "[data-testid='#{test_id}'][aria-label='Click to sort']") html =~ ~r/aria-label=["'].*sort.*["']/i
# Check that data-testid is present for testing
assert html =~ ~r/data-testid=["']custom_field_#{field.id}["']/
end end
test "sort header component shows correct ARIA label when sorted ascending", %{ test "sort header component shows correct ARIA label when sorted ascending", %{
@ -68,9 +71,10 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsAccessibilityTest do
{:ok, view, _html} = {:ok, view, _html} =
live(conn, "/members?query=&sort_field=custom_field_#{field.id}&sort_order=asc") live(conn, "/members?query=&sort_field=custom_field_#{field.id}&sort_order=asc")
# Check that aria-label indicates ascending sort using data-testid html = render(view)
test_id = "custom_field_#{field.id}"
assert has_element?(view, "[data-testid='#{test_id}'][aria-label='ascending']") # Check that aria-label indicates ascending sort
assert html =~ ~r/aria-label=["'].*ascending.*["']/i
end end
test "sort header component shows correct ARIA label when sorted descending", %{ test "sort header component shows correct ARIA label when sorted descending", %{
@ -82,21 +86,21 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsAccessibilityTest do
{:ok, view, _html} = {:ok, view, _html} =
live(conn, "/members?query=&sort_field=custom_field_#{field.id}&sort_order=desc") live(conn, "/members?query=&sort_field=custom_field_#{field.id}&sort_order=desc")
# Check that aria-label indicates descending sort using data-testid html = render(view)
test_id = "custom_field_#{field.id}"
assert has_element?(view, "[data-testid='#{test_id}'][aria-label='descending']") # Check that aria-label indicates descending sort
assert html =~ ~r/aria-label=["'].*descending.*["']/i
end end
test "custom field column header is keyboard accessible", %{conn: conn, field: field} do test "custom field column header is keyboard accessible", %{conn: conn, field: field} 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")
# Check that the sort button is a button element (keyboard accessible) # Check that the sort button is a button element (keyboard accessible)
test_id = "custom_field_#{field.id}" assert html =~ ~r/<button[^>]*data-testid=["']custom_field_#{field.id}["']/
assert has_element?(view, "button[data-testid='#{test_id}']")
# Button should not have tabindex="-1" (which would remove from tab order) # Button should not have tabindex="-1" (which would remove from tab order)
refute has_element?(view, "button[data-testid='#{test_id}'][tabindex='-1']") refute html =~ ~r/tabindex=["']-1["']/
end end
test "custom field column header has proper semantic structure", %{conn: conn, field: field} do test "custom field column header has proper semantic structure", %{conn: conn, field: field} do

View file

@ -410,17 +410,15 @@ defmodule MvWeb.MemberLive.IndexTest do
assert render(view) =~ "1" assert render(view) =~ "1"
end end
test "copy button is disabled when no members selected", %{conn: conn} do test "copy button is not visible when no members are selected", %{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")
# Copy button should be disabled (button element) # Ensure no members are selected (default state)
assert has_element?(view, "#copy-emails-btn[disabled]") refute has_element?(view, "#copy-emails-btn")
# Open email button should be disabled (link with tabindex and aria-disabled)
assert has_element?(view, "#open-email-btn[tabindex='-1'][aria-disabled='true']")
end end
test "copy button is enabled after selection", %{ test "copy button is visible when members are selected", %{
conn: conn, conn: conn,
member1: member1 member1: member1
} do } do
@ -430,13 +428,8 @@ defmodule MvWeb.MemberLive.IndexTest do
# Select a member by sending the select_member event directly # Select a member by sending the select_member event directly
render_click(view, "select_member", %{"id" => member1.id}) render_click(view, "select_member", %{"id" => member1.id})
# Copy button should now be enabled (no disabled attribute) # Button should now be visible
refute has_element?(view, "#copy-emails-btn[disabled]") assert has_element?(view, "#copy-emails-btn")
# Open email button should now be enabled (no tabindex=-1 or aria-disabled)
refute has_element?(view, "#open-email-btn[tabindex='-1']")
refute has_element?(view, "#open-email-btn[aria-disabled='true']")
# Counter should show correct count
assert render(view) =~ "1"
end end
test "copy button click triggers event and shows flash", %{ test "copy button click triggers event and shows flash", %{