Compare commits

...

14 commits

Author SHA1 Message Date
a7285915e6 docs: fix CalendarCycles documentation to match actual implementation
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-16 15:06:45 +01:00
da6c495d04 refactor: improve CalendarCycles API and tests based on code review 2025-12-16 15:06:45 +01:00
3fc4440bce feat: implement calendar-based cycle calculation functions
Add CalendarCycles module with functions for all interval types.
Includes comprehensive tests for edge cases.
2025-12-16 15:06:45 +01:00
0a07f4f212 Merge pull request 'Small UX fixes closes #281' (#293) from feature/281_uxfixes into main
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #293
2025-12-16 15:06:00 +01:00
1df1b4b238 test: use data-testids instead of regex in a11y tests
All checks were successful
continuous-integration/drone/push Build is passing
Replace regex-based aria-label assertions with data-testid-based
has_element? checks for more stable tests that are resistant to
translation changes.
2025-12-16 14:55:50 +01:00
62d04add8e fix: standardize 'Custom Field' capitalization in i18n
Change 'Save Custom field' to 'Save Custom Field' and
'Save Custom field value' to 'Save Custom Field Value' for consistency.
Update gettext files accordingly.
2025-12-16 14:54:43 +01:00
9f9d888657 test: add tests for disabled button states in member index
Add tests to verify that copy and open-email buttons are disabled
when no members are selected and enabled after selection.
Also verify that the counter shows the correct count.
2025-12-16 14:53:10 +01:00
be6ea56860 fix: improve mailto BCC encoding
Use URI.encode_www_form() instead of URI.encode() for mailto query parameters.
This is the safer choice for query parameter encoding.

Add comment about mailto URL length limits that vary by email client.
2025-12-16 14:51:42 +01:00
fb91f748c2 perf: optimize member index selection calculations
Calculate selected_count, any_selected? and mailto_bcc once in assigns
instead of recalculating Enum.any? and Enum.count multiple times in template.
This improves render performance and makes the template code more readable.
2025-12-16 14:50:52 +01:00
222af635ae fix: make disabled links more robust in CoreComponents.button
Remove navigation attributes (href, navigate, patch) when disabled=true
to prevent 'Open in new tab' and 'Copy link' from working on disabled links.
This makes the disabled state semantically stronger and independent of CSS themes.
2025-12-16 14:48:18 +01:00
dd4048669c fix: update clubname on save
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-16 14:35:00 +01:00
e0712d47bc chore: change payment filter text 2025-12-16 14:35:00 +01:00
4e86351e1c feat: disable email buttons instead hide them 2025-12-16 14:35:00 +01:00
8bfa5b7d1d chore: remove immutable from custom fields 2025-12-16 14:35:00 +01:00
21 changed files with 759 additions and 153 deletions

View file

@ -12,7 +12,6 @@ defmodule Mv.Membership.CustomField do
- `slug` - URL-friendly, immutable identifier automatically generated from name (e.g., "phone-mobile")
- `value_type` - Data type constraint (`:string`, `:integer`, `:boolean`, `:date`, `:email`)
- `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)
- `show_in_overview` - If true, this custom field will be displayed in the member overview table and can be sorted
@ -60,10 +59,10 @@ defmodule Mv.Membership.CustomField do
actions do
defaults [:read, :update]
default_accept [:name, :value_type, :description, :immutable, :required, :show_in_overview]
default_accept [:name, :value_type, :description, :required, :show_in_overview]
create :create do
accept [:name, :value_type, :description, :immutable, :required, :show_in_overview]
accept [:name, :value_type, :description, :required, :show_in_overview]
change Mv.Membership.CustomField.Changes.GenerateSlug
validate string_length(:slug, min: 1)
end
@ -113,10 +112,6 @@ defmodule Mv.Membership.CustomField do
trim?: true
]
attribute :immutable, :boolean,
default: false,
allow_nil?: false
attribute :required, :boolean,
default: false,
allow_nil?: false

View file

@ -0,0 +1,329 @@
defmodule Mv.MembershipFees.CalendarCycles do
@moduledoc """
Calendar-based cycle calculation functions for membership fees.
This module provides functions for calculating cycle boundaries
based on interval types (monthly, quarterly, half-yearly, yearly).
The calculation functions (`calculate_cycle_start/3`, `calculate_cycle_end/2`,
`next_cycle_start/2`) are pure functions with no side effects.
The time-dependent functions (`current_cycle?/3`, `last_completed_cycle?/3`)
depend on a date parameter for testability. Their 2-argument variants
(`current_cycle?/2`, `last_completed_cycle?/2`) use `Date.utc_today()` and
are not referentially transparent.
## Interval Types
- `:monthly` - Cycles from 1st to last day of each month
- `:quarterly` - Cycles from 1st of Jan/Apr/Jul/Oct to last day of quarter
- `:half_yearly` - Cycles from 1st of Jan/Jul to last day of half-year
- `:yearly` - Cycles from Jan 1st to Dec 31st
## Examples
iex> date = ~D[2024-03-15]
iex> Mv.MembershipFees.CalendarCycles.calculate_cycle_start(date, :monthly)
~D[2024-03-01]
iex> cycle_start = ~D[2024-01-01]
iex> Mv.MembershipFees.CalendarCycles.calculate_cycle_end(cycle_start, :yearly)
~D[2024-12-31]
iex> cycle_start = ~D[2024-01-01]
iex> Mv.MembershipFees.CalendarCycles.next_cycle_start(cycle_start, :yearly)
~D[2025-01-01]
"""
@typedoc """
Interval type for membership fee cycles.
- `:monthly` - Monthly cycles (1st to last day of month)
- `:quarterly` - Quarterly cycles (1st of quarter to last day of quarter)
- `:half_yearly` - Half-yearly cycles (1st of half-year to last day of half-year)
- `:yearly` - Yearly cycles (Jan 1st to Dec 31st)
"""
@type interval :: :monthly | :quarterly | :half_yearly | :yearly
@doc """
Calculates the start date of the cycle that contains the reference date.
## Parameters
- `date` - Ignored in this 3-argument version (kept for API consistency)
- `interval` - The interval type (`:monthly`, `:quarterly`, `:half_yearly`, `:yearly`)
- `reference_date` - The date used to determine which cycle to calculate
## Returns
The start date of the cycle containing the reference date.
## Examples
iex> Mv.MembershipFees.CalendarCycles.calculate_cycle_start(~D[2024-03-15], :monthly, ~D[2024-05-20])
~D[2024-05-01]
iex> Mv.MembershipFees.CalendarCycles.calculate_cycle_start(~D[2024-03-15], :quarterly, ~D[2024-05-20])
~D[2024-04-01]
"""
@spec calculate_cycle_start(Date.t(), interval(), Date.t()) :: Date.t()
def calculate_cycle_start(_date, interval, reference_date) do
case interval do
:monthly -> monthly_cycle_start(reference_date)
:quarterly -> quarterly_cycle_start(reference_date)
:half_yearly -> half_yearly_cycle_start(reference_date)
:yearly -> yearly_cycle_start(reference_date)
end
end
@doc """
Calculates the start date of the cycle that contains the given date.
This is a convenience function that calls `calculate_cycle_start/3` with `date` as both
the input and reference date.
## Parameters
- `date` - The date used to determine which cycle to calculate
- `interval` - The interval type (`:monthly`, `:quarterly`, `:half_yearly`, `:yearly`)
## Returns
The start date of the cycle containing the given date.
## Examples
iex> Mv.MembershipFees.CalendarCycles.calculate_cycle_start(~D[2024-03-15], :monthly)
~D[2024-03-01]
iex> Mv.MembershipFees.CalendarCycles.calculate_cycle_start(~D[2024-05-15], :quarterly)
~D[2024-04-01]
iex> Mv.MembershipFees.CalendarCycles.calculate_cycle_start(~D[2024-09-15], :half_yearly)
~D[2024-07-01]
iex> Mv.MembershipFees.CalendarCycles.calculate_cycle_start(~D[2024-12-15], :yearly)
~D[2024-01-01]
"""
@spec calculate_cycle_start(Date.t(), interval()) :: Date.t()
def calculate_cycle_start(date, interval) do
calculate_cycle_start(date, interval, date)
end
@doc """
Calculates the end date of a cycle based on its start date and interval.
## Parameters
- `cycle_start` - The start date of the cycle
- `interval` - The interval type
## Returns
The end date of the cycle.
## Examples
iex> Mv.MembershipFees.CalendarCycles.calculate_cycle_end(~D[2024-03-01], :monthly)
~D[2024-03-31]
iex> Mv.MembershipFees.CalendarCycles.calculate_cycle_end(~D[2024-02-01], :monthly)
~D[2024-02-29]
iex> Mv.MembershipFees.CalendarCycles.calculate_cycle_end(~D[2024-01-01], :quarterly)
~D[2024-03-31]
iex> Mv.MembershipFees.CalendarCycles.calculate_cycle_end(~D[2024-01-01], :half_yearly)
~D[2024-06-30]
iex> Mv.MembershipFees.CalendarCycles.calculate_cycle_end(~D[2024-01-01], :yearly)
~D[2024-12-31]
"""
@spec calculate_cycle_end(Date.t(), interval()) :: Date.t()
def calculate_cycle_end(cycle_start, interval) do
case interval do
:monthly -> monthly_cycle_end(cycle_start)
:quarterly -> quarterly_cycle_end(cycle_start)
:half_yearly -> half_yearly_cycle_end(cycle_start)
:yearly -> yearly_cycle_end(cycle_start)
end
end
@doc """
Calculates the start date of the next cycle.
## Parameters
- `cycle_start` - The start date of the current cycle
- `interval` - The interval type
## Returns
The start date of the next cycle.
## Examples
iex> Mv.MembershipFees.CalendarCycles.next_cycle_start(~D[2024-01-01], :monthly)
~D[2024-02-01]
iex> Mv.MembershipFees.CalendarCycles.next_cycle_start(~D[2024-01-01], :quarterly)
~D[2024-04-01]
iex> Mv.MembershipFees.CalendarCycles.next_cycle_start(~D[2024-01-01], :half_yearly)
~D[2024-07-01]
iex> Mv.MembershipFees.CalendarCycles.next_cycle_start(~D[2024-01-01], :yearly)
~D[2025-01-01]
"""
@spec next_cycle_start(Date.t(), interval()) :: Date.t()
def next_cycle_start(cycle_start, interval) do
cycle_end = calculate_cycle_end(cycle_start, interval)
next_date = Date.add(cycle_end, 1)
calculate_cycle_start(next_date, interval)
end
@doc """
Checks if the cycle contains the given date.
## Parameters
- `cycle_start` - The start date of the cycle
- `interval` - The interval type
- `today` - The date to check (defaults to today's date)
## Returns
`true` if the given date is within the cycle, `false` otherwise.
## Examples
iex> Mv.MembershipFees.CalendarCycles.current_cycle?(~D[2024-03-01], :monthly, ~D[2024-03-15])
true
iex> Mv.MembershipFees.CalendarCycles.current_cycle?(~D[2024-02-01], :monthly, ~D[2024-03-15])
false
iex> Mv.MembershipFees.CalendarCycles.current_cycle?(~D[2024-03-01], :monthly, ~D[2024-03-01])
true
iex> Mv.MembershipFees.CalendarCycles.current_cycle?(~D[2024-03-01], :monthly, ~D[2024-03-31])
true
"""
@spec current_cycle?(Date.t(), interval(), Date.t()) :: boolean()
def current_cycle?(cycle_start, interval, today) do
cycle_end = calculate_cycle_end(cycle_start, interval)
Date.compare(cycle_start, today) in [:lt, :eq] and
Date.compare(today, cycle_end) in [:lt, :eq]
end
@spec current_cycle?(Date.t(), interval()) :: boolean()
def current_cycle?(cycle_start, interval) do
current_cycle?(cycle_start, interval, Date.utc_today())
end
@doc """
Checks if the cycle is the last completed cycle.
A cycle is considered the last completed cycle if:
- The cycle has ended (cycle_end < today)
- The next cycle has not ended yet (today <= next_end)
In other words: `cycle_end < today <= next_end`
## Parameters
- `cycle_start` - The start date of the cycle
- `interval` - The interval type
- `today` - The date to check against (defaults to today's date)
## Returns
`true` if the cycle is the last completed cycle, `false` otherwise.
## Examples
iex> Mv.MembershipFees.CalendarCycles.last_completed_cycle?(~D[2024-03-01], :monthly, ~D[2024-04-01])
true
iex> Mv.MembershipFees.CalendarCycles.last_completed_cycle?(~D[2024-03-01], :monthly, ~D[2024-03-15])
false
iex> Mv.MembershipFees.CalendarCycles.last_completed_cycle?(~D[2024-02-01], :monthly, ~D[2024-04-15])
false
"""
@spec last_completed_cycle?(Date.t(), interval(), Date.t()) :: boolean()
def last_completed_cycle?(cycle_start, interval, today) do
cycle_end = calculate_cycle_end(cycle_start, interval)
# Cycle must have ended (cycle_end < today)
case Date.compare(today, cycle_end) do
:gt ->
# Check if this is the most recent completed cycle
# by verifying that the next cycle hasn't ended yet (today <= next_end)
next_start = next_cycle_start(cycle_start, interval)
next_end = calculate_cycle_end(next_start, interval)
Date.compare(today, next_end) in [:lt, :eq]
_ ->
false
end
end
@spec last_completed_cycle?(Date.t(), interval()) :: boolean()
def last_completed_cycle?(cycle_start, interval) do
last_completed_cycle?(cycle_start, interval, Date.utc_today())
end
# Private helper functions
defp monthly_cycle_start(date) do
Date.new!(date.year, date.month, 1)
end
defp monthly_cycle_end(cycle_start) do
Date.end_of_month(cycle_start)
end
defp quarterly_cycle_start(date) do
quarter_start_month =
case date.month do
m when m in [1, 2, 3] -> 1
m when m in [4, 5, 6] -> 4
m when m in [7, 8, 9] -> 7
m when m in [10, 11, 12] -> 10
end
Date.new!(date.year, quarter_start_month, 1)
end
defp quarterly_cycle_end(cycle_start) do
case cycle_start.month do
1 -> Date.new!(cycle_start.year, 3, 31)
4 -> Date.new!(cycle_start.year, 6, 30)
7 -> Date.new!(cycle_start.year, 9, 30)
10 -> Date.new!(cycle_start.year, 12, 31)
end
end
defp half_yearly_cycle_start(date) do
half_start_month = if date.month in 1..6, do: 1, else: 7
Date.new!(date.year, half_start_month, 1)
end
defp half_yearly_cycle_end(cycle_start) do
case cycle_start.month do
1 -> Date.new!(cycle_start.year, 6, 30)
7 -> Date.new!(cycle_start.year, 12, 31)
end
end
defp yearly_cycle_start(date) do
Date.new!(date.year, 1, 1)
end
defp yearly_cycle_end(cycle_start) do
Date.new!(cycle_start.year, 12, 31)
end
end

View file

@ -95,9 +95,11 @@ defmodule MvWeb.CoreComponents do
<.button>Send!</.button>
<.button phx-click="go" variant="primary">Send!</.button>
<.button navigate={~p"/"}>Home</.button>
<.button disabled={true}>Disabled</.button>
"""
attr :rest, :global, include: ~w(href navigate patch method)
attr :variant, :string, values: ~w(primary)
attr :disabled, :boolean, default: false, doc: "Whether the button is disabled"
slot :inner_block, required: true
def button(%{rest: rest} = assigns) do
@ -105,14 +107,37 @@ defmodule MvWeb.CoreComponents do
assigns = assign(assigns, :class, Map.fetch!(variants, assigns[:variant]))
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"""
<.link class={["btn", @class]} {@rest}>
<.link class={@link_class} {@link_attrs}>
{render_slot(@inner_block)}
</.link>
"""
else
~H"""
<button class={["btn", @class]} {@rest}>
<button class={["btn", @class]} disabled={@disabled} {@rest}>
{render_slot(@inner_block)}
</button>
"""

View file

@ -36,12 +36,16 @@ defmodule MvWeb.Layouts do
default: nil,
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
def app(assigns) do
~H"""
<%= if @current_user do %>
<.navbar current_user={@current_user} />
<.navbar current_user={@current_user} club_name={@club_name} />
<% end %>
<main class="px-4 py-20 sm:px-6 lg:px-16">
<div class="mx-auto max-full space-y-4">

View file

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

View file

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

View file

@ -6,7 +6,7 @@ defmodule MvWeb.CustomFieldLive.FormComponent do
- Create new custom field definitions
- Edit existing custom fields
- Select value type from supported types
- Set immutable and required flags
- Set required flag
- Real-time validation
## Props
@ -50,10 +50,10 @@ defmodule MvWeb.CustomFieldLive.FormComponent do
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)
}
/>
<.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[:show_in_overview]}
@ -66,7 +66,7 @@ defmodule MvWeb.CustomFieldLive.FormComponent do
{gettext("Cancel")}
</.button>
<.button phx-disable-with={gettext("Saving...")} variant="primary">
{gettext("Save Custom field")}
{gettext("Save Custom Field")}
</.button>
</div>
</.form>

View file

@ -5,7 +5,7 @@ defmodule MvWeb.CustomFieldLive.IndexComponent do
## Features
- List all custom fields
- Display type information (name, value type, description)
- Show immutable and required flags
- Show required flag
- Create new custom fields
- Edit existing custom fields
- 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-target={@myself}
>
<.icon name="hero-plus" /> {gettext("New Custom field")}
<.icon name="hero-plus" /> {gettext("New Custom Field")}
</.button>
</div>
</div>

View file

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

View file

@ -37,7 +37,7 @@ defmodule MvWeb.GlobalSettingsLive do
@impl true
def render(assigns) do
~H"""
<Layouts.app flash={@flash} current_user={@current_user}>
<Layouts.app flash={@flash} current_user={@current_user} club_name={@settings.club_name}>
<.header>
{gettext("Settings")}
<:subtitle>
@ -80,10 +80,13 @@ defmodule MvWeb.GlobalSettingsLive do
@impl true
def handle_event("save", %{"setting" => setting_params}, socket) 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
|> assign(:settings, updated_settings)
|> assign(:settings, fresh_settings)
|> put_flash(:info, gettext("Settings updated successfully"))
|> assign_form()

View file

@ -145,7 +145,10 @@ defmodule MvWeb.MemberLive.Index do
MapSet.put(socket.assigns.selected_members, id)
end
{:noreply, assign(socket, :selected_members, selected)}
{:noreply,
socket
|> assign(:selected_members, selected)
|> update_selection_assigns()}
end
@impl true
@ -159,7 +162,10 @@ defmodule MvWeb.MemberLive.Index do
all_ids
end
{:noreply, assign(socket, :selected_members, selected)}
{:noreply,
socket
|> assign(:selected_members, selected)
|> update_selection_assigns()}
end
@impl true
@ -238,6 +244,7 @@ defmodule MvWeb.MemberLive.Index do
socket
|> assign(:query, q)
|> load_members()
|> update_selection_assigns()
existing_field_query = socket.assigns.sort_field
existing_sort_query = socket.assigns.sort_order
@ -263,6 +270,7 @@ defmodule MvWeb.MemberLive.Index do
socket
|> assign(:paid_filter, filter)
|> load_members()
|> update_selection_assigns()
# Build the URL with all params including new filter
query_params =
@ -309,6 +317,7 @@ defmodule MvWeb.MemberLive.Index do
|> assign(:visible_custom_field_ids, extract_custom_field_ids(visible_custom_fields))
|> load_members()
|> prepare_dynamic_cols()
|> update_selection_assigns()
|> push_field_selection_url()
{:noreply, socket}
@ -338,6 +347,7 @@ defmodule MvWeb.MemberLive.Index do
|> assign(:visible_custom_field_ids, extract_custom_field_ids(visible_custom_fields))
|> load_members()
|> prepare_dynamic_cols()
|> update_selection_assigns()
|> push_field_selection_url()
{:noreply, socket}
@ -389,6 +399,7 @@ defmodule MvWeb.MemberLive.Index do
|> assign(:visible_custom_field_ids, extract_custom_field_ids(visible_custom_fields))
|> load_members()
|> prepare_dynamic_cols()
|> update_selection_assigns()
{:noreply, socket}
end
@ -1112,4 +1123,34 @@ defmodule MvWeb.MemberLive.Index do
# Public helper function to format dates for use in templates
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

View file

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

View file

@ -26,7 +26,7 @@
"esbuild": {:hex, :esbuild, "0.10.0", "b0aa3388a1c23e727c5a3e7427c932d89ee791746b0081bbe56103e9ef3d291f", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "468489cda427b974a7cc9f03ace55368a83e1a7be12fba7e30969af78e5f8c70"},
"ets": {:hex, :ets, "0.9.0", "79c6a6c205436780486f72d84230c6cba2f8a9920456750ddd1e47389107d5fd", [:mix], [], "hexpm", "2861fdfb04bcaeff370f1a5904eec864f0a56dcfebe5921ea9aadf2a481c822b"},
"ex_phone_number": {:hex, :ex_phone_number, "0.4.8", "c1c5e6f0673822a2a7b439b43af7d3eb1a5c19ae582b772b8b8d12625dd51ec1", [:mix], [{:sweet_xml, "~> 0.7", [hex: :sweet_xml, repo: "hexpm", optional: false]}], "hexpm", "43e2357c6b8cfe556bcd417f4ce9aaef267a786e31a2938902daaa0d36f69757"},
"expo": {:hex, :expo, "1.1.1", "4202e1d2ca6e2b3b63e02f69cfe0a404f77702b041d02b58597c00992b601db5", [], [], "hexpm", "5fb308b9cb359ae200b7e23d37c76978673aa1b06e2b3075d814ce12c5811640"},
"expo": {:hex, :expo, "1.1.1", "4202e1d2ca6e2b3b63e02f69cfe0a404f77702b041d02b58597c00992b601db5", [:mix], [], "hexpm", "5fb308b9cb359ae200b7e23d37c76978673aa1b06e2b3075d814ce12c5811640"},
"file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"},
"finch": {:hex, :finch, "0.20.0", "5330aefb6b010f424dcbbc4615d914e9e3deae40095e73ab0c1bb0968933cadf", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "2658131a74d051aabfcba936093c903b8e89da9a1b63e430bee62045fa9b2ee2"},
"fine": {:hex, :fine, "0.1.4", "b19a89c1476c7c57afb5f9314aed5960b5bc95d5277de4cb5ee8e1d1616ce379", [:mix], [], "hexpm", "be3324cc454a42d80951cf6023b9954e9ff27c6daa255483b3e8d608670303f5"},
@ -39,7 +39,7 @@
"iterex": {:hex, :iterex, "0.1.2", "58f9b9b9a22a55cbfc7b5234a9c9c63eaac26d276b3db80936c0e1c60355a5a6", [:mix], [], "hexpm", "2e103b8bcc81757a9af121f6dc0df312c9a17220f302b1193ef720460d03029d"},
"jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"},
"joken": {:hex, :joken, "2.6.2", "5daaf82259ca603af4f0b065475099ada1b2b849ff140ccd37f4b6828ca6892a", [:mix], [{:jose, "~> 1.11.10", [hex: :jose, repo: "hexpm", optional: false]}], "hexpm", "5134b5b0a6e37494e46dbf9e4dad53808e5e787904b7c73972651b51cce3d72b"},
"jose": {:hex, :jose, "1.11.12", "06e62b467b61d3726cbc19e9b5489f7549c37993de846dfb3ee8259f9ed208b3", [], [], "hexpm", "31e92b653e9210b696765cdd885437457de1add2a9011d92f8cf63e4641bab7b"},
"jose": {:hex, :jose, "1.11.12", "06e62b467b61d3726cbc19e9b5489f7549c37993de846dfb3ee8259f9ed208b3", [:mix, :rebar3], [], "hexpm", "31e92b653e9210b696765cdd885437457de1add2a9011d92f8cf63e4641bab7b"},
"lazy_html": {:hex, :lazy_html, "0.1.8", "677a8642e644eef8de98f3040e2520d42d0f0f8bd6c5cd49db36504e34dffe91", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.9.0", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:fine, "~> 0.1.0", [hex: :fine, repo: "hexpm", optional: false]}], "hexpm", "0d8167d930b704feb94b41414ca7f5779dff9bca7fcf619fcef18de138f08736"},
"libgraph": {:hex, :libgraph, "0.16.0", "3936f3eca6ef826e08880230f806bfea13193e49bf153f93edcf0239d4fd1d07", [:mix], [], "hexpm", "41ca92240e8a4138c30a7e06466acc709b0cbb795c643e9e17174a178982d6bf"},
"live_debugger": {:hex, :live_debugger, "0.5.0", "95e0f7727d61010f7e9053923fb2a9416904a7533c2dfb36120e7684cba4c0af", [:mix], [{:igniter, ">= 0.5.40 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.7", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.20.8 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}], "hexpm", "73ebe95118d22aa402675f677abd731cb16b136d1b6ae5f4010441fb50753b14"},
@ -80,7 +80,7 @@
"telemetry_metrics": {:hex, :telemetry_metrics, "1.1.0", "5bd5f3b5637e0abea0426b947e3ce5dd304f8b3bc6617039e2b5a008adc02f8f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e7b79e8ddfde70adb6db8a6623d1778ec66401f366e9a8f5dd0955c56bc8ce67"},
"telemetry_poller": {:hex, :telemetry_poller, "1.3.0", "d5c46420126b5ac2d72bc6580fb4f537d35e851cc0f8dbd571acf6d6e10f5ec7", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "51f18bed7128544a50f75897db9974436ea9bfba560420b646af27a9a9b35211"},
"text_diff": {:hex, :text_diff, "0.1.0", "1caf3175e11a53a9a139bc9339bd607c47b9e376b073d4571c031913317fecaa", [:mix], [], "hexpm", "d1ffaaecab338e49357b6daa82e435f877e0649041ace7755583a0ea3362dbd7"},
"thousand_island": {:hex, :thousand_island, "1.4.2", "735fa783005d1703359bbd2d3a5a3a398075ba4456e5afe3c5b7cf4666303d36", [], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "1c7637f16558fc1c35746d5ee0e83b18b8e59e18d28affd1f2fa1645f8bc7473"},
"thousand_island": {:hex, :thousand_island, "1.4.2", "735fa783005d1703359bbd2d3a5a3a398075ba4456e5afe3c5b7cf4666303d36", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "1c7637f16558fc1c35746d5ee0e83b18b8e59e18d28affd1f2fa1645f8bc7473"},
"tidewave": {:hex, :tidewave, "0.5.2", "f549acffe9daeed8b6b547c232c60de987770da7f827f9b3300140dfc465b102", [:mix], [{:circular_buffer, "~> 0.4 or ~> 1.0", [hex: :circular_buffer, repo: "hexpm", optional: false]}, {:igniter, "~> 0.6", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix_live_reload, ">= 1.6.1", [hex: :phoenix_live_reload, repo: "hexpm", optional: true]}, {:plug, "~> 1.17", [hex: :plug, repo: "hexpm", optional: false]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}], "hexpm", "34ab3ffee7e402f05cd1eae68d0e77ed0e0d1925677971ef83634247553e8afd"},
"unicode_util_compat": {:hex, :unicode_util_compat, "0.7.1", "a48703a25c170eedadca83b11e88985af08d35f37c6f664d6dcfb106a97782fc", [:rebar3], [], "hexpm", "b3a917854ce3ae233619744ad1e0102e05673136776fb2fa76234f3e03b23642"},
"websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"},

View file

@ -282,11 +282,6 @@ msgstr "Benutzer*in bearbeiten"
msgid "Enabled"
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
#, elixir-autogen, elixir-format
msgid "Logout"
@ -612,16 +607,6 @@ msgstr "Benutzerdefinierter Feldwert erfolgreich %{action}"
msgid "Please select a custom field first"
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/member_live/form.ex
#: lib/mv_web/live/member_live/show.ex
@ -760,11 +745,6 @@ msgstr[1] "%{count} E-Mail-Adressen in die Zwischenablage kopiert"
msgid "Copy email addresses of selected members"
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
#, elixir-autogen, elixir-format
msgid "No email addresses found"
@ -796,7 +776,6 @@ msgid "This field cannot be empty"
msgstr "Dieses Feld darf nicht leer bleiben"
#: lib/mv_web/components/core_components.ex
#: lib/mv_web/live/components/payment_filter_component.ex
#, elixir-autogen, elixir-format
msgid "All"
msgstr "Alle"
@ -1389,14 +1368,10 @@ msgid "Failed to delete custom field: %{error}"
msgstr "Konnte Feld nicht löschen: %{error}"
#: lib/mv_web/live/custom_field_live/form_component.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "New Custom Field"
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"
msgid "New Custom Field"
msgstr "Neues Benutzerdefiniertes Feld"
#: lib/mv_web/live/global_settings_live.ex
#, elixir-autogen, elixir-format
@ -1438,6 +1413,26 @@ msgstr "Textfeld"
msgid "Yes/No-Selection"
msgstr "Ja/Nein-Auswahl"
#: 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)"
@ -1449,6 +1444,11 @@ msgstr "Ja/Nein-Auswahl"
#~ msgid "Birth Date"
#~ msgstr "Geburtsdatum"
#~ #: 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
@ -1471,6 +1471,16 @@ msgstr "Ja/Nein-Auswahl"
#~ msgid "Id"
#~ msgstr "ID"
#~ #: lib/mv_web/live/custom_field_live/form_component.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Immutable"
#~ msgstr "Unveränderlich"
#~ #: 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

View file

@ -283,11 +283,6 @@ msgstr ""
msgid "Enabled"
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
#, elixir-autogen, elixir-format
msgid "Logout"
@ -613,16 +608,6 @@ msgstr ""
msgid "Please select a custom field first"
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/member_live/form.ex
#: lib/mv_web/live/member_live/show.ex
@ -761,11 +746,6 @@ msgstr[1] ""
msgid "Copy email addresses of selected members"
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
#, elixir-autogen, elixir-format
msgid "No email addresses found"
@ -797,7 +777,6 @@ msgid "This field cannot be empty"
msgstr ""
#: lib/mv_web/components/core_components.ex
#: lib/mv_web/live/components/payment_filter_component.ex
#, elixir-autogen, elixir-format
msgid "All"
msgstr ""
@ -1390,13 +1369,9 @@ msgid "Failed to delete custom field: %{error}"
msgstr ""
#: lib/mv_web/live/custom_field_live/form_component.ex
#, elixir-autogen, elixir-format
msgid "New Custom Field"
msgstr ""
#: lib/mv_web/live/custom_field_live/index_component.ex
#, elixir-autogen, elixir-format
msgid "New Custom field"
msgid "New Custom Field"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
@ -1438,3 +1413,23 @@ msgstr ""
#, elixir-autogen, elixir-format
msgid "Yes/No-Selection"
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,11 +283,6 @@ msgstr ""
msgid "Enabled"
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
#, elixir-autogen, elixir-format
msgid "Logout"
@ -613,16 +608,6 @@ msgstr ""
msgid "Please select a custom field first"
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/member_live/form.ex
#: lib/mv_web/live/member_live/show.ex
@ -761,11 +746,6 @@ msgstr[1] ""
msgid "Copy email addresses of selected members"
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
#, elixir-autogen, elixir-format
msgid "No email addresses found"
@ -797,7 +777,6 @@ msgid "This field cannot be empty"
msgstr ""
#: lib/mv_web/components/core_components.ex
#: lib/mv_web/live/components/payment_filter_component.ex
#, elixir-autogen, elixir-format
msgid "All"
msgstr ""
@ -1390,13 +1369,9 @@ msgid "Failed to delete custom field: %{error}"
msgstr ""
#: lib/mv_web/live/custom_field_live/form_component.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "New Custom Field"
msgstr ""
#: lib/mv_web/live/custom_field_live/index_component.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "New Custom field"
msgid "New Custom Field"
msgstr ""
#: lib/mv_web/live/global_settings_live.ex
@ -1439,6 +1414,26 @@ msgstr ""
msgid "Yes/No-Selection"
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, 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
#~ msgid "Auto-generated identifier (immutable)"
@ -1450,6 +1445,11 @@ msgstr ""
#~ msgid "Birth Date"
#~ msgstr ""
#~ #: lib/mv_web/live/member_live/index.html.heex
#~ #, elixir-autogen, elixir-format
#~ msgid "Copy emails"
#~ msgstr ""
#~ #: lib/mv_web/live/member_live/form.ex
#~ #: lib/mv_web/live/member_live/show.ex
#~ #, elixir-autogen, elixir-format
@ -1471,6 +1471,16 @@ msgstr ""
#~ msgid "Id"
#~ msgstr ""
#~ #: lib/mv_web/live/custom_field_live/form_component.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Immutable"
#~ 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"

View file

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

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

View file

@ -0,0 +1,181 @@
defmodule Mv.MembershipFees.CalendarCyclesTest do
@moduledoc """
Tests for CalendarCycles module.
"""
use ExUnit.Case, async: true
alias Mv.MembershipFees.CalendarCycles
doctest Mv.MembershipFees.CalendarCycles
describe "calculate_cycle_start/3" do
test "uses reference_date when provided" do
date = ~D[2024-03-15]
reference = ~D[2024-05-20]
assert CalendarCycles.calculate_cycle_start(date, :monthly, reference) == ~D[2024-05-01]
assert CalendarCycles.calculate_cycle_start(date, :quarterly, reference) == ~D[2024-04-01]
end
end
describe "current_cycle?/3" do
# Basic examples are covered by doctests
test "works for all interval types" do
today = ~D[2024-03-15]
for interval <- [:monthly, :quarterly, :half_yearly, :yearly] do
cycle_start = CalendarCycles.calculate_cycle_start(today, interval)
result = CalendarCycles.current_cycle?(cycle_start, interval, today)
assert result == true, "Expected current cycle for #{interval} with start #{cycle_start}"
end
end
end
describe "current_cycle?/2 wrapper" do
test "calls current_cycle?/3 with Date.utc_today()" do
today = Date.utc_today()
cycle_start = CalendarCycles.calculate_cycle_start(today, :monthly)
# This test verifies the wrapper works, but uses actual today
# The real testing happens in current_cycle?/3 tests above
result = CalendarCycles.current_cycle?(cycle_start, :monthly)
assert result == true
end
end
describe "last_completed_cycle?/3" do
# Basic examples are covered by doctests
test "returns false when next cycle has also ended" do
# Two cycles ago: cycle ended, but next cycle also ended
today = ~D[2024-05-15]
cycle_start = ~D[2024-03-01]
# Cycle ended 2024-03-31, next cycle ended 2024-04-30, today is 2024-05-15
assert CalendarCycles.last_completed_cycle?(cycle_start, :monthly, today) == false
end
test "works correctly for quarterly intervals" do
# Q1 2024 ended on 2024-03-31
# Q2 2024 ends on 2024-06-30
# Today is 2024-04-15 (after Q1 ended, before Q2 ended)
today = ~D[2024-04-15]
past_quarter_start = ~D[2024-01-01]
assert CalendarCycles.last_completed_cycle?(past_quarter_start, :quarterly, today) == true
end
test "returns false when cycle ended on the given date" do
# Cycle ends on today, so it's still current, not completed
today = ~D[2024-03-31]
cycle_start = ~D[2024-03-01]
assert CalendarCycles.last_completed_cycle?(cycle_start, :monthly, today) == false
end
end
describe "last_completed_cycle?/2 wrapper" do
test "calls last_completed_cycle?/3 with Date.utc_today()" do
today = Date.utc_today()
cycle_start = CalendarCycles.calculate_cycle_start(today, :monthly)
# This test verifies the wrapper works, but uses actual today
# The real testing happens in last_completed_cycle?/3 tests above
result = CalendarCycles.last_completed_cycle?(cycle_start, :monthly)
# Result depends on actual today, so we just verify it's a boolean
assert is_boolean(result)
end
end
describe "edge cases" do
test "leap year: February has 29 days" do
# 2024 is a leap year
assert CalendarCycles.calculate_cycle_end(~D[2024-02-01], :monthly) == ~D[2024-02-29]
# 2023 is not a leap year
assert CalendarCycles.calculate_cycle_end(~D[2023-02-01], :monthly) == ~D[2023-02-28]
end
test "year boundary: December 31 to January 1" do
# Yearly cycle
assert CalendarCycles.next_cycle_start(~D[2024-01-01], :yearly) == ~D[2025-01-01]
# Monthly cycle across year boundary
assert CalendarCycles.next_cycle_start(~D[2024-12-01], :monthly) == ~D[2025-01-01]
# Half-yearly cycle across year boundary
assert CalendarCycles.next_cycle_start(~D[2024-07-01], :half_yearly) == ~D[2025-01-01]
# Quarterly cycle across year boundary
assert CalendarCycles.next_cycle_start(~D[2024-10-01], :quarterly) == ~D[2025-01-01]
end
test "month boundary: different month lengths" do
# 31-day months
assert CalendarCycles.calculate_cycle_end(~D[2024-01-01], :monthly) == ~D[2024-01-31]
assert CalendarCycles.calculate_cycle_end(~D[2024-03-01], :monthly) == ~D[2024-03-31]
assert CalendarCycles.calculate_cycle_end(~D[2024-05-01], :monthly) == ~D[2024-05-31]
# 30-day months
assert CalendarCycles.calculate_cycle_end(~D[2024-04-01], :monthly) == ~D[2024-04-30]
assert CalendarCycles.calculate_cycle_end(~D[2024-06-01], :monthly) == ~D[2024-06-30]
assert CalendarCycles.calculate_cycle_end(~D[2024-09-01], :monthly) == ~D[2024-09-30]
assert CalendarCycles.calculate_cycle_end(~D[2024-11-01], :monthly) == ~D[2024-11-30]
end
test "date in middle of cycle: all functions work correctly" do
middle_date = ~D[2024-03-15]
# calculate_cycle_start
assert CalendarCycles.calculate_cycle_start(middle_date, :monthly) == ~D[2024-03-01]
assert CalendarCycles.calculate_cycle_start(middle_date, :quarterly) == ~D[2024-01-01]
assert CalendarCycles.calculate_cycle_start(middle_date, :half_yearly) == ~D[2024-01-01]
assert CalendarCycles.calculate_cycle_start(middle_date, :yearly) == ~D[2024-01-01]
# calculate_cycle_end
monthly_start = CalendarCycles.calculate_cycle_start(middle_date, :monthly)
assert CalendarCycles.calculate_cycle_end(monthly_start, :monthly) == ~D[2024-03-31]
# next_cycle_start
assert CalendarCycles.next_cycle_start(monthly_start, :monthly) == ~D[2024-04-01]
end
test "quarterly: all quarter boundaries correct" do
# Q1 boundaries
assert CalendarCycles.calculate_cycle_start(~D[2024-01-15], :quarterly) == ~D[2024-01-01]
assert CalendarCycles.calculate_cycle_end(~D[2024-01-01], :quarterly) == ~D[2024-03-31]
# Q2 boundaries
assert CalendarCycles.calculate_cycle_start(~D[2024-05-15], :quarterly) == ~D[2024-04-01]
assert CalendarCycles.calculate_cycle_end(~D[2024-04-01], :quarterly) == ~D[2024-06-30]
# Q3 boundaries
assert CalendarCycles.calculate_cycle_start(~D[2024-08-15], :quarterly) == ~D[2024-07-01]
assert CalendarCycles.calculate_cycle_end(~D[2024-07-01], :quarterly) == ~D[2024-09-30]
# Q4 boundaries
assert CalendarCycles.calculate_cycle_start(~D[2024-11-15], :quarterly) == ~D[2024-10-01]
assert CalendarCycles.calculate_cycle_end(~D[2024-10-01], :quarterly) == ~D[2024-12-31]
end
test "half_yearly: both half boundaries correct" do
# First half boundaries
assert CalendarCycles.calculate_cycle_start(~D[2024-03-15], :half_yearly) == ~D[2024-01-01]
assert CalendarCycles.calculate_cycle_end(~D[2024-01-01], :half_yearly) == ~D[2024-06-30]
# Second half boundaries
assert CalendarCycles.calculate_cycle_start(~D[2024-09-15], :half_yearly) == ~D[2024-07-01]
assert CalendarCycles.calculate_cycle_end(~D[2024-07-01], :half_yearly) == ~D[2024-12-31]
end
test "yearly: full year boundaries" do
assert CalendarCycles.calculate_cycle_start(~D[2024-06-15], :yearly) == ~D[2024-01-01]
assert CalendarCycles.calculate_cycle_end(~D[2024-01-01], :yearly) == ~D[2024-12-31]
assert CalendarCycles.next_cycle_start(~D[2024-01-01], :yearly) == ~D[2025-01-01]
end
end
end

View file

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

View file

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