Merge branch 'main' into feature/209_hide_field_dropdown
Some checks failed
continuous-integration/drone/push Build is failing

This commit is contained in:
carla 2025-12-03 12:52:12 +01:00
commit f0613fe1e5
29 changed files with 1661 additions and 405 deletions

View file

@ -24,7 +24,7 @@ defmodule Mv.Membership.Member do
- Email format validation (using EctoCommons.EmailValidator)
- Phone number format: international format with 6-20 digits
- Postal code format: exactly 5 digits (German format)
- Date validations: birth_date and join_date not in future, exit_date after join_date
- Date validations: join_date not in future, exit_date after join_date
- Email uniqueness: prevents conflicts with unlinked users
## Full-Text Search
@ -284,11 +284,6 @@ defmodule Mv.Membership.Member do
end
end
# Birth date not in the future
validate compare(:birth_date, less_than_or_equal_to: &Date.utc_today/0),
where: [present(:birth_date)],
message: "cannot be in the future"
# Join date not in the future
validate compare(:join_date, less_than_or_equal_to: &Date.utc_today/0),
where: [present(:join_date)],
@ -351,10 +346,6 @@ defmodule Mv.Membership.Member do
constraints min_length: 5, max_length: 254
end
attribute :birth_date, :date do
allow_nil? true
end
attribute :paid, :boolean do
allow_nil? true
end

View file

@ -134,8 +134,8 @@ defmodule Mv.Membership do
## Parameters
- `settings` - The settings record to update
- `visibility_config` - A map of member field names (atoms) to boolean visibility values
(e.g., `%{street: false, house_number: false}`)
- `visibility_config` - A map of member field names (strings) to boolean visibility values
(e.g., `%{"street" => false, "house_number" => false}`)
## Returns
@ -145,9 +145,9 @@ defmodule Mv.Membership do
## Examples
iex> {:ok, settings} = Mv.Membership.get_settings()
iex> {:ok, updated} = Mv.Membership.update_member_field_visibility(settings, %{street: false, house_number: false})
iex> {:ok, updated} = Mv.Membership.update_member_field_visibility(settings, %{"street" => false, "house_number" => false})
iex> updated.member_field_visibility
%{street: false, house_number: false}
%{"street" => false, "house_number" => false}
"""
def update_member_field_visibility(settings, visibility_config) do

View file

@ -10,7 +10,7 @@ defmodule Mv.Membership.Setting do
## Attributes
- `club_name` - The name of the association/club (required, cannot be empty)
- `member_field_visibility` - JSONB map storing visibility configuration for member fields
(e.g., `%{street: false, house_number: false}`). Fields not in the map default to `true`.
(e.g., `%{"street" => false, "house_number" => false}`). Fields not in the map default to `true`.
## Singleton Pattern
This resource uses a singleton pattern - there should only be one settings record.
@ -32,7 +32,7 @@ defmodule Mv.Membership.Setting do
{:ok, updated} = Mv.Membership.update_settings(settings, %{club_name: "New Name"})
# Update member field visibility
{:ok, updated} = Mv.Membership.update_member_field_visibility(settings, %{street: false, house_number: false})
{:ok, updated} = Mv.Membership.update_member_field_visibility(settings, %{"street" => false, "house_number" => false})
"""
use Ash.Resource,
domain: Mv.Membership,
@ -67,43 +67,6 @@ defmodule Mv.Membership.Setting do
description "Updates the visibility configuration for member fields in the overview"
require_atomic? false
accept [:member_field_visibility]
change fn changeset, _context ->
visibility = Ash.Changeset.get_attribute(changeset, :member_field_visibility)
if visibility && is_map(visibility) do
valid_fields = Mv.Constants.member_fields()
# Normalize keys to atoms (JSONB may return string keys)
invalid_keys =
Enum.filter(visibility, fn {key, _value} ->
atom_key =
if is_atom(key) do
key
else
try do
String.to_existing_atom(key)
rescue
ArgumentError -> nil
end
end
atom_key && atom_key not in valid_fields
end)
|> Enum.map(fn {key, _value} -> key end)
if Enum.empty?(invalid_keys) do
changeset
else
Ash.Changeset.add_error(
changeset,
field: :member_field_visibility,
message: "Invalid member field keys: #{inspect(invalid_keys)}"
)
end
else
changeset
end
end
end
end
@ -111,23 +74,39 @@ defmodule Mv.Membership.Setting do
validate present(:club_name), on: [:create, :update]
validate string_length(:club_name, min: 1), on: [:create, :update]
# Validate that member_field_visibility map contains only boolean values
# This allows dynamic fields without hardcoding specific field names
# Validate member_field_visibility map structure and content
validate fn changeset, _context ->
visibility = Ash.Changeset.get_attribute(changeset, :member_field_visibility)
if visibility && is_map(visibility) do
invalid_entries =
# Validate all values are booleans
invalid_values =
Enum.filter(visibility, fn {_key, value} ->
not is_boolean(value)
end)
if Enum.empty?(invalid_entries) do
:ok
else
{:error,
field: :member_field_visibility,
message: "All values in member_field_visibility must be booleans"}
# Validate all keys are valid member fields
valid_field_strings = Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)
invalid_keys =
Enum.filter(visibility, fn {key, _value} ->
key not in valid_field_strings
end)
|> Enum.map(fn {key, _value} -> key end)
cond do
not Enum.empty?(invalid_values) ->
{:error,
field: :member_field_visibility,
message: "All values in member_field_visibility must be booleans"}
not Enum.empty?(invalid_keys) ->
{:error,
field: :member_field_visibility,
message: "Invalid member field keys: #{inspect(invalid_keys)}"}
true ->
:ok
end
else
:ok

View file

@ -7,7 +7,6 @@ defmodule Mv.Constants do
:first_name,
:last_name,
:email,
:birth_date,
:paid,
:phone_number,
:join_date,

View file

@ -42,7 +42,11 @@ defmodule MvWeb.CoreComponents do
attr :id, :string, doc: "the optional id of flash container"
attr :flash, :map, default: %{}, doc: "the map of flash messages to display"
attr :title, :string, default: nil
attr :kind, :atom, values: [:info, :error], doc: "used for styling and flash lookup"
attr :kind, :atom,
values: [:info, :error, :success, :warning],
doc: "used for styling and flash lookup"
attr :rest, :global, doc: "the arbitrary HTML attributes to add to the flash container"
slot :inner_block, doc: "the optional inner block that renders the flash message"
@ -56,16 +60,20 @@ defmodule MvWeb.CoreComponents do
id={@id}
phx-click={JS.push("lv:clear-flash", value: %{key: @kind}) |> hide("##{@id}")}
role="alert"
class="toast toast-top toast-end z-50"
class="z-50 toast toast-top toast-end"
{@rest}
>
<div class={[
"alert w-80 sm:w-96 max-w-80 sm:max-w-96 text-wrap",
@kind == :info && "alert-info",
@kind == :error && "alert-error"
@kind == :error && "alert-error",
@kind == :success && "bg-green-500 text-white",
@kind == :warning && "bg-blue-100 text-blue-800 border border-blue-300"
]}>
<.icon :if={@kind == :info} name="hero-information-circle" class="size-5 shrink-0" />
<.icon :if={@kind == :error} name="hero-exclamation-circle" class="size-5 shrink-0" />
<.icon :if={@kind == :success} name="hero-check-circle" class="size-5 shrink-0" />
<.icon :if={@kind == :warning} name="hero-information-circle" class="size-5 shrink-0" />
<div>
<p :if={@title} class="font-semibold">{@title}</p>
<p>{msg}</p>
@ -300,7 +308,7 @@ defmodule MvWeb.CoreComponents do
end)
~H"""
<fieldset class="fieldset mb-2">
<fieldset class="mb-2 fieldset">
<label>
<input type="hidden" name={@name} value="false" disabled={@rest[:disabled]} />
<span class="label">
@ -312,7 +320,11 @@ defmodule MvWeb.CoreComponents do
checked={@checked}
class={@class || "checkbox checkbox-sm"}
{@rest}
/>{@label}
/>{@label}<span
:if={@rest[:required]}
class="text-red-700 tooltip tooltip-right"
data-tip={gettext("This field cannot be empty")}
>*</span>
</span>
</label>
<.error :for={msg <- @errors}>{msg}</.error>
@ -322,9 +334,15 @@ defmodule MvWeb.CoreComponents do
def input(%{type: "select"} = assigns) do
~H"""
<fieldset class="fieldset mb-2">
<fieldset class="mb-2 fieldset">
<label>
<span :if={@label} class="label mb-1">{@label}</span>
<span :if={@label} class="mb-1 label">
{@label}<span
:if={@rest[:required]}
class="text-red-700 tooltip tooltip-right"
data-tip={gettext("This field cannot be empty")}
>*</span>
</span>
<select
id={@id}
name={@name}
@ -343,9 +361,15 @@ defmodule MvWeb.CoreComponents do
def input(%{type: "textarea"} = assigns) do
~H"""
<fieldset class="fieldset mb-2">
<fieldset class="mb-2 fieldset">
<label>
<span :if={@label} class="label mb-1">{@label}</span>
<span :if={@label} class="mb-1 label">
{@label}<span
:if={@rest[:required]}
class="text-red-700 tooltip tooltip-right"
data-tip={gettext("This field cannot be empty")}
>*</span>
</span>
<textarea
id={@id}
name={@name}
@ -364,9 +388,15 @@ defmodule MvWeb.CoreComponents do
# All other inputs text, datetime-local, url, password, etc. are handled here...
def input(assigns) do
~H"""
<fieldset class="fieldset mb-2">
<fieldset class="mb-2 fieldset">
<label>
<span :if={@label} class="label mb-1">{@label}</span>
<span :if={@label} class="mb-1 label">
{@label}<span
:if={@rest[:required]}
class="text-red-700 tooltip tooltip-right"
data-tip={gettext("This field cannot be empty")}
>*</span>
</span>
<input
type={@type}
name={@name}
@ -637,7 +667,7 @@ defmodule MvWeb.CoreComponents do
<div class="mt-14">
<dl class="-my-4 divide-y divide-zinc-100">
<div :for={{name, value} <- @items} class="flex gap-4 py-4 text-sm leading-6 sm:gap-8">
<dt class="w-1/4 flex-none text-zinc-500">{name}</dt>
<dt class="flex-none w-1/4 text-zinc-500">{name}</dt>
<dd class="text-zinc-700">{value}</dd>
</div>
</dl>

View file

@ -65,7 +65,9 @@ defmodule MvWeb.Layouts do
def flash_group(assigns) do
~H"""
<div id={@id} aria-live="polite">
<div id={@id} aria-live="polite" class="toast toast-top toast-end z-50 flex flex-col gap-2">
<.flash kind={:success} flash={@flash} />
<.flash kind={:warning} flash={@flash} />
<.flash kind={:info} flash={@flash} />
<.flash kind={:error} flash={@flash} />

View file

@ -0,0 +1,146 @@
defmodule MvWeb.Components.PaymentFilterComponent do
@moduledoc """
Provides the PaymentFilter Live-Component.
A dropdown filter for filtering members by payment status (paid/not paid/all).
Uses DaisyUI dropdown styling and sends filter changes to parent LiveView.
## Props
- `:paid_filter` - Current filter state: `nil` (all), `:paid`, or `:not_paid`
- `:id` - Component ID (required)
- `:member_count` - Number of filtered members to display in badge (optional, default: 0)
## Events
- Sends `{:payment_filter_changed, filter}` to parent when filter changes
"""
use MvWeb, :live_component
@impl true
def mount(socket) do
{:ok, assign(socket, :open, false)}
end
@impl true
def update(assigns, socket) do
socket =
socket
|> assign(:id, assigns.id)
|> assign(:paid_filter, assigns[:paid_filter])
|> assign(:member_count, assigns[:member_count] || 0)
{:ok, socket}
end
@impl true
def render(assigns) do
~H"""
<div
class="relative"
id={@id}
phx-window-keydown={@open && "close_dropdown"}
phx-key="Escape"
phx-target={@myself}
>
<button
type="button"
class={[
"btn btn-ghost gap-2",
@paid_filter && "btn-active"
]}
phx-click="toggle_dropdown"
phx-target={@myself}
aria-haspopup="true"
aria-expanded={to_string(@open)}
aria-label={gettext("Filter by payment status")}
>
<.icon name="hero-funnel" class="h-5 w-5" />
<span class="hidden sm:inline">{filter_label(@paid_filter)}</span>
<span :if={@paid_filter} class="badge badge-primary badge-sm">{@member_count}</span>
</button>
<ul
:if={@open}
class="menu dropdown-content bg-base-100 rounded-box z-10 w-52 p-2 shadow-lg absolute right-0 mt-2"
role="menu"
aria-label={gettext("Payment filter")}
phx-click-away="close_dropdown"
phx-target={@myself}
>
<li role="none">
<button
type="button"
role="menuitemradio"
aria-checked={to_string(@paid_filter == nil)}
class={@paid_filter == nil && "active"}
phx-click="select_filter"
phx-value-filter=""
phx-target={@myself}
>
<.icon name="hero-users" class="h-4 w-4" />
{gettext("All")}
</button>
</li>
<li role="none">
<button
type="button"
role="menuitemradio"
aria-checked={to_string(@paid_filter == :paid)}
class={@paid_filter == :paid && "active"}
phx-click="select_filter"
phx-value-filter="paid"
phx-target={@myself}
>
<.icon name="hero-check-circle" class="h-4 w-4 text-success" />
{gettext("Paid")}
</button>
</li>
<li role="none">
<button
type="button"
role="menuitemradio"
aria-checked={to_string(@paid_filter == :not_paid)}
class={@paid_filter == :not_paid && "active"}
phx-click="select_filter"
phx-value-filter="not_paid"
phx-target={@myself}
>
<.icon name="hero-x-circle" class="h-4 w-4 text-error" />
{gettext("Not paid")}
</button>
</li>
</ul>
</div>
"""
end
@impl true
def handle_event("toggle_dropdown", _params, socket) do
{:noreply, assign(socket, :open, !socket.assigns.open)}
end
@impl true
def handle_event("close_dropdown", _params, socket) do
{:noreply, assign(socket, :open, false)}
end
@impl true
def handle_event("select_filter", %{"filter" => filter_str}, socket) do
filter = parse_filter(filter_str)
# Close dropdown and notify parent
socket = assign(socket, :open, false)
send(self(), {:payment_filter_changed, filter})
{:noreply, socket}
end
# Parse filter string to atom
defp parse_filter("paid"), do: :paid
defp parse_filter("not_paid"), do: :not_paid
defp parse_filter(_), do: nil
# Get display label for current filter
defp filter_label(nil), do: gettext("All")
defp filter_label(:paid), do: gettext("Paid")
defp filter_label(:not_paid), do: gettext("Not paid")
end

View file

@ -14,7 +14,7 @@ defmodule MvWeb.MemberLive.Form do
- first_name, last_name, email
**Optional:**
- birth_date, phone_number, address fields (city, street, house_number, postal_code)
- phone_number, address fields (city, street, house_number, postal_code)
- join_date, exit_date
- paid status
- notes
@ -37,7 +37,7 @@ defmodule MvWeb.MemberLive.Form do
<.header>
{@page_title}
<:subtitle>
{gettext("Use this form to manage member records and their properties.")}
{gettext("Fields marked with an asterisk (*) cannot be empty.")}
</:subtitle>
</.header>
@ -45,7 +45,6 @@ defmodule MvWeb.MemberLive.Form do
<.input field={@form[:first_name]} label={gettext("First Name")} required />
<.input field={@form[:last_name]} label={gettext("Last Name")} required />
<.input field={@form[:email]} label={gettext("Email")} required type="email" />
<.input field={@form[:birth_date]} label={gettext("Birth Date")} type="date" />
<.input field={@form[:paid]} label={gettext("Paid")} type="checkbox" />
<.input field={@form[:phone_number]} label={gettext("Phone Number")} />
<.input field={@form[:join_date]} label={gettext("Join Date")} type="date" />

View file

@ -18,6 +18,7 @@ defmodule MvWeb.MemberLive.Index do
- `delete` - Remove a member from the database
- `select_member` - Toggle individual member selection
- `select_all` - Toggle selection of all visible members
- `copy_emails` - Copy email addresses of selected members to clipboard
## Implementation Notes
- Search uses PostgreSQL full-text search (plainto_tsquery)
@ -47,7 +48,7 @@ defmodule MvWeb.MemberLive.Index do
Initializes the LiveView state.
Sets up initial assigns for page title, search query, sort configuration,
and member selection. Actual data loading happens in `handle_params/3`.
payment filter, and member selection. Actual data loading happens in `handle_params/3`.
"""
@impl true
def mount(_params, session, socket) do
@ -95,7 +96,8 @@ defmodule MvWeb.MemberLive.Index do
|> assign(:query, "")
|> assign_new(:sort_field, fn -> :first_name end)
|> assign_new(:sort_order, fn -> :asc end)
|> assign(:selected_members, [])
|> assign(:paid_filter, nil)
|> assign(:selected_members, MapSet.new())
|> assign(:settings, settings)
|> assign(:custom_fields_visible, custom_fields_visible)
|> assign(:all_custom_fields, all_custom_fields)
@ -106,6 +108,7 @@ defmodule MvWeb.MemberLive.Index do
:member_fields_visible,
FieldVisibility.get_visible_member_fields(initial_selection)
)
|> assign(:member_fields_visible, get_visible_member_fields(settings))
# We call handle params to use the query from the URL
{:ok, socket}
@ -137,10 +140,10 @@ defmodule MvWeb.MemberLive.Index do
@impl true
def handle_event("select_member", %{"id" => id}, socket) do
selected =
if id in socket.assigns.selected_members do
List.delete(socket.assigns.selected_members, id)
if MapSet.member?(socket.assigns.selected_members, id) do
MapSet.delete(socket.assigns.selected_members, id)
else
[id | socket.assigns.selected_members]
MapSet.put(socket.assigns.selected_members, id)
end
{:noreply, assign(socket, :selected_members, selected)}
@ -148,13 +151,11 @@ defmodule MvWeb.MemberLive.Index do
@impl true
def handle_event("select_all", _params, socket) do
members = socket.assigns.members
all_ids = Enum.map(members, & &1.id)
all_ids = socket.assigns.members |> Enum.map(& &1.id) |> MapSet.new()
selected =
if Enum.sort(socket.assigns.selected_members) == Enum.sort(all_ids) do
[]
if MapSet.equal?(socket.assigns.selected_members, all_ids) do
MapSet.new()
else
all_ids
end
@ -162,6 +163,52 @@ defmodule MvWeb.MemberLive.Index do
{:noreply, assign(socket, :selected_members, selected)}
end
@impl true
def handle_event("copy_emails", _params, socket) do
selected_ids = socket.assigns.selected_members
# Filter members that are in the selection and have email addresses
formatted_emails =
socket.assigns.members
|> Enum.filter(fn member ->
MapSet.member?(selected_ids, member.id) && member.email && member.email != ""
end)
|> Enum.map(&format_member_email/1)
email_count = length(formatted_emails)
cond do
MapSet.size(selected_ids) == 0 ->
{:noreply, put_flash(socket, :error, gettext("No members selected"))}
email_count == 0 ->
{:noreply, put_flash(socket, :error, gettext("No email addresses found"))}
true ->
# RFC 5322 uses comma as separator for email address lists
email_string = Enum.join(formatted_emails, ", ")
socket =
socket
|> push_event("copy_to_clipboard", %{text: email_string})
|> put_flash(
:success,
ngettext(
"Copied %{count} email address to clipboard",
"Copied %{count} email addresses to clipboard",
email_count,
count: email_count
)
)
|> put_flash(
:warning,
gettext("Tip: Paste email addresses into the BCC field for privacy compliance")
)
{:noreply, socket}
end
end
# -----------------------------------------------------------------
# Handle Infos from Child Components
# -----------------------------------------------------------------
@ -194,22 +241,17 @@ defmodule MvWeb.MemberLive.Index do
@impl true
def handle_info({:search_changed, q}, socket) do
# Update query assign first
socket = assign(socket, :query, q)
# Load members with the new query
socket = load_members(socket, q)
socket =
socket
|> assign(:query, q)
|> load_members()
existing_field_query = socket.assigns.sort_field
existing_sort_query = socket.assigns.sort_order
# Build the URL with queries
query_params =
build_query_params(socket, %{
"query" => q,
"sort_field" => existing_field_query,
"sort_order" => existing_sort_query
})
build_query_params(q, existing_field_query, existing_sort_query, socket.assigns.paid_filter)
# Set the new path with params
new_path = ~p"/members?#{query_params}"
@ -222,6 +264,31 @@ defmodule MvWeb.MemberLive.Index do
)}
end
@impl true
def handle_info({:payment_filter_changed, filter}, socket) do
socket =
socket
|> assign(:paid_filter, filter)
|> load_members()
# Build the URL with all params including new filter
query_params =
build_query_params(
socket.assigns.query,
socket.assigns.sort_field,
socket.assigns.sort_order,
filter
)
new_path = ~p"/members?#{query_params}"
{:noreply,
push_patch(socket,
to: new_path,
replace: true
)}
end
@impl true
def handle_info({:field_toggled, field_string, visible}, socket) do
# Update user field selection
@ -289,7 +356,7 @@ defmodule MvWeb.MemberLive.Index do
@doc """
Handles URL parameter changes.
Parses query parameters for search query, sort field, sort order, and field selection,
Parses query parameters for search query, sort field, sort order, and payment filter, and field selection,
then loads members accordingly. This enables bookmarkable URLs and
browser back/forward navigation.
"""
@ -322,10 +389,12 @@ defmodule MvWeb.MemberLive.Index do
socket
|> maybe_update_search(params)
|> maybe_update_sort(params)
|> maybe_update_paid_filter(params)
|> assign(:query, params["query"])
|> assign(:user_field_selection, final_selection)
|> assign(:member_fields_visible, visible_member_fields)
|> assign(:visible_custom_field_ids, extract_custom_field_ids(visible_custom_fields))
|> load_members(params["query"])
|> load_members()
|> prepare_dynamic_cols()
{:noreply, socket}
@ -423,10 +492,12 @@ defmodule MvWeb.MemberLive.Index do
end
query_params =
build_query_params(socket, %{
"sort_field" => field_str,
"sort_order" => Atom.to_string(order)
})
build_query_params(
socket.assigns.query,
field_str,
Atom.to_string(order),
socket.assigns.paid_filter
)
new_path = ~p"/members?#{query_params}"
@ -481,13 +552,45 @@ defmodule MvWeb.MemberLive.Index do
assign(socket, :user_field_selection, selection)
end
# Loads members from the database with custom field values and applies search/sort filters.
# Builds URL query parameters map including all filter/sort state.
# Converts paid_filter atom to string for URL.
defp build_query_params(query, sort_field, sort_order, paid_filter) do
field_str =
if is_atom(sort_field) do
Atom.to_string(sort_field)
else
sort_field
end
order_str =
if is_atom(sort_order) do
Atom.to_string(sort_order)
else
sort_order
end
base_params = %{
"query" => query,
"sort_field" => field_str,
"sort_order" => order_str
}
# Only add paid_filter to URL if it's set
case paid_filter do
nil -> base_params
:paid -> Map.put(base_params, "paid_filter", "paid")
:not_paid -> Map.put(base_params, "paid_filter", "not_paid")
end
end
# Loads members from the database with custom field values and applies search/sort/payment filters.
#
# Process:
# 1. Builds base query with selected fields
# 2. Loads custom field values for visible custom fields (filtered at database level)
# 3. Applies search filter if provided
# 4. Applies sorting (database-level for regular fields, in-memory for custom fields)
# 4. Applies payment status filter if set
# 5. Applies sorting (database-level for regular fields, in-memory for custom fields)
#
# Performance Considerations:
# - Database-level filtering: Custom field values are filtered directly in the database
@ -499,7 +602,9 @@ defmodule MvWeb.MemberLive.Index do
# consider implementing pagination (see Issue #165).
#
# Returns the socket with `:members` assigned.
defp load_members(socket, search_query) do
defp load_members(socket) do
search_query = socket.assigns.query
query =
Mv.Membership.Member
|> Ash.Query.new()
@ -512,6 +617,9 @@ defmodule MvWeb.MemberLive.Index do
# Apply the search filter first
query = apply_search_filter(query, search_query)
# Apply payment status filter
query = apply_paid_filter(query, socket.assigns.paid_filter)
# Apply sorting based on current socket state
# For custom fields, we sort after loading
{query, sort_after_load} =
@ -586,6 +694,24 @@ defmodule MvWeb.MemberLive.Index do
end
end
# Applies payment status filter to the query.
#
# Filter values:
# - nil: No filter, return all members
# - :paid: Only members with paid == true
# - :not_paid: Members with paid == false or paid == nil (not paid)
defp apply_paid_filter(query, nil), do: query
defp apply_paid_filter(query, :paid) do
Ash.Query.filter(query, expr(paid == true))
end
defp apply_paid_filter(query, :not_paid) do
# Include both false and nil as "not paid"
# Note: paid != true doesn't work correctly with NULL values in SQL
Ash.Query.filter(query, expr(paid == false or is_nil(paid)))
end
# Functions to toggle sorting order
defp toggle_order(:asc), do: :desc
defp toggle_order(:desc), do: :asc
@ -876,6 +1002,29 @@ defmodule MvWeb.MemberLive.Index do
socket
end
# Updates paid filter from URL parameters if present.
#
# Validates the filter value, falling back to nil (no filter) if invalid.
defp maybe_update_paid_filter(socket, %{"paid_filter" => filter_str}) do
filter = determine_paid_filter(filter_str)
assign(socket, :paid_filter, filter)
end
defp maybe_update_paid_filter(socket, _params) do
# Reset filter if not in URL params
assign(socket, :paid_filter, nil)
end
# Determines valid paid filter from URL parameter.
#
# SECURITY: This function whitelists allowed filter values. Only "paid" and "not_paid"
# are accepted - all other input (including malicious strings) falls back to nil.
# This ensures no raw user input is ever passed to Ash.Query.filter/2, following
# Ash's security recommendation to never pass untrusted input directly to filters.
defp determine_paid_filter("paid"), do: :paid
defp determine_paid_filter("not_paid"), do: :not_paid
defp determine_paid_filter(_), do: nil
# -------------------------------------------------------------
# Helper Functions for Custom Field Values
# -------------------------------------------------------------
@ -908,11 +1057,28 @@ defmodule MvWeb.MemberLive.Index do
end
end
# Gets the configuration for all member fields with their show_in_overview values.
# Formats a member's email in the format "First Last <email>"
# Used for copy_emails feature to create email-client-friendly format.
defp format_member_email(member) do
first_name = member.first_name || ""
last_name = member.last_name || ""
name =
[first_name, last_name]
|> Enum.filter(&(&1 != ""))
|> Enum.join(" ")
if name == "" do
member.email
else
"#{name} <#{member.email}>"
end
end
# Gets the list of member fields that should be visible in the overview.
#
# Reads the visibility configuration from Settings and returns a map with all member fields
# and their show_in_overview values (true or false). Fields not configured in settings
# default to true.
# Reads the visibility configuration from Settings and returns only the fields
# where show_in_overview is true. Fields not configured in settings default to true.
#
# Performance: This function uses the already-loaded settings to avoid N+1 queries.
# Settings should be loaded once in mount/3 and passed to this function.
@ -920,62 +1086,20 @@ defmodule MvWeb.MemberLive.Index do
# Parameters:
# - `settings` - The settings struct loaded from the database
#
# Returns a map: %{field_name => show_in_overview}
#
# This can be used for:
# - Rendering the overview (filtering visible fields)
# - UI configuration dropdowns (showing all fields with their current state)
# - Dynamic field management
# Returns a list of atoms representing visible member field names.
#
# Fields are read from the global Constants module.
@spec get_member_field_configurations(map()) :: %{atom() => boolean()}
defp get_member_field_configurations(settings) do
@spec get_visible_member_fields(map()) :: [atom()]
defp get_visible_member_fields(settings) do
# Get all eligible fields from the global constants
all_fields = Mv.Constants.member_fields()
# Normalize visibility config (JSONB may return string keys)
visibility_config = normalize_visibility_config(settings.member_field_visibility || %{})
# JSONB stores keys as strings
visibility_config = settings.member_field_visibility || %{}
Enum.reduce(all_fields, %{}, fn field, acc ->
show_in_overview = Map.get(visibility_config, field, true)
Map.put(acc, field, show_in_overview)
# Filter to only return visible fields
Enum.filter(all_fields, fn field ->
Map.get(visibility_config, Atom.to_string(field), true)
end)
end
# Normalizes visibility config map keys from strings to atoms.
# JSONB in PostgreSQL converts atom keys to string keys when storing.
# This is a local helper to avoid N+1 queries by reusing the normalization logic.
defp normalize_visibility_config(config) when is_map(config) do
Enum.reduce(config, %{}, fn
{key, value}, acc when is_atom(key) ->
Map.put(acc, key, value)
{key, value}, acc when is_binary(key) ->
try do
atom_key = String.to_existing_atom(key)
Map.put(acc, atom_key, value)
rescue
ArgumentError ->
acc
end
_, acc ->
acc
end)
end
defp normalize_visibility_config(_), do: %{}
# Extracts custom field IDs from visible custom field strings
# Format: "custom_field_<id>" -> <id>
defp extract_custom_field_ids(visible_custom_fields) do
Enum.map(visible_custom_fields, fn field_string ->
case String.split(field_string, "custom_field_") do
["", id] -> id
_ -> nil
end
end)
|> Enum.filter(&(&1 != nil))
end
end

View file

@ -9,18 +9,44 @@
custom_fields={@all_custom_fields}
selected_fields={@user_field_selection}
/>
<.button
:if={Enum.any?(@members, &MapSet.member?(@selected_members, &1.id))}
id="copy-emails-btn"
phx-hook="CopyToClipboard"
phx-click="copy_emails"
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))})
</.button>
<.button
:if={Enum.any?(@members, &MapSet.member?(@selected_members, &1.id))}
href={"mailto:?bcc=#{@members |> Enum.filter(&(MapSet.member?(@selected_members, &1.id) && &1.email)) |> Enum.map(& &1.email) |> Enum.join(",")}"}
aria-label={gettext("Open email program with BCC recipients")}
>
<.icon name="hero-envelope" />
{gettext("Open in email program")}
</.button>
<.button variant="primary" navigate={~p"/members/new"}>
<.icon name="hero-plus" /> {gettext("New Member")}
</.button>
</:actions>
</.header>
<.live_component
module={MvWeb.Components.SearchBarComponent}
id="search-bar"
query={@query}
placeholder={gettext("Search...")}
/>
<div class="flex flex-wrap gap-4 items-center">
<.live_component
module={MvWeb.Components.SearchBarComponent}
id="search-bar"
query={@query}
placeholder={gettext("Search...")}
/>
<.live_component
module={MvWeb.Components.PaymentFilterComponent}
id="payment-filter"
paid_filter={@paid_filter}
member_count={length(@members)}
/>
</div>
<.table
id="members"
@ -40,7 +66,7 @@
type="checkbox"
name="select_all"
phx-click="select_all"
checked={Enum.sort(@selected_members) == Enum.map(@members, & &1.id) |> Enum.sort()}
checked={MapSet.equal?(@selected_members, @members |> Enum.map(& &1.id) |> MapSet.new())}
aria-label={gettext("Select all members")}
role="checkbox"
/>
@ -52,7 +78,7 @@
name={member.id}
phx-click="select_member"
phx-value-id={member.id}
checked={member.id in @selected_members}
checked={MapSet.member?(@selected_members, member.id)}
phx-capture-click
phx-stop-propagation
aria-label={gettext("Select member")}
@ -221,6 +247,14 @@
>
{member.join_date}
</:col>
<:col :let={member} label={gettext("Paid")}>
<span class={[
"badge",
if(member.paid == true, do: "badge-success", else: "badge-error")
]}>
{if member.paid == true, do: gettext("Yes"), else: gettext("No")}
</span>
</:col>
<:action :let={member}>
<div class="sr-only">
<.link navigate={~p"/members/#{member}"}>{gettext("Show")}</.link>

View file

@ -10,7 +10,7 @@ defmodule MvWeb.MemberLive.Show do
- Return to member list
## Displayed Information
- Basic: name, email, dates (birth, join, exit)
- Basic: name, email, dates (join, exit)
- Contact: phone number
- Address: street, house number, postal code, city
- Status: paid flag
@ -48,7 +48,6 @@ defmodule MvWeb.MemberLive.Show do
<:item title={gettext("First Name")}>{@member.first_name}</:item>
<:item title={gettext("Last Name")}>{@member.last_name}</:item>
<:item title={gettext("Email")}>{@member.email}</:item>
<:item title={gettext("Birth Date")}>{@member.birth_date}</:item>
<:item title={gettext("Paid")}>
{if @member.paid, do: gettext("Yes"), else: gettext("No")}
</:item>