Compare commits

...

11 commits

Author SHA1 Message Date
4a09ab1f7b
fix: add ESC key support, security comment, and disable async tests
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-02 15:06:44 +01:00
f5a525d8ff
fix: add role=none to li elements in payment filter for ARIA compliance 2025-12-02 15:06:44 +01:00
5d69fbe387
feat: add payment status filter and paid column to member list
Add PaymentFilterComponent dropdown and colored paid column. Filter supports URL bookmarking and combines with search/sort.
2025-12-02 15:06:39 +01:00
40835f7a2d Merge pull request 'Implement setting to show/hide member fields technically closes #214' (#232) from feature/214_hide_memberfields into main
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #232
2025-12-02 14:33:08 +01:00
13f77b5c0a
Refactor column visibility logic
All checks were successful
continuous-integration/drone/push Build is passing
2025-12-02 14:18:27 +01:00
dce2053ce7 formatting and refactor member fields constant 2025-12-02 14:17:53 +01:00
e81aecce48 feat: adds member visibility to live view 2025-12-02 14:17:04 +01:00
397cbde9d6 feat: adds member visibility settings 2025-12-02 14:16:02 +01:00
831149f463 chore: adds constant for member_fields 2025-12-02 14:16:02 +01:00
944b868478 tests: adds tests 2025-12-02 14:16:02 +01:00
d10f2ecc90 chore: adds migration for member field visibility 2025-12-02 14:16:02 +01:00
18 changed files with 1333 additions and 139 deletions

View file

@ -42,6 +42,10 @@ defmodule Mv.Membership.Member do
@member_search_limit 10
@default_similarity_threshold 0.2
# Use constants from Mv.Constants for member fields
# This ensures consistency across the codebase
@member_fields Mv.Constants.member_fields()
postgres do
table "members"
repo Mv.Repo
@ -58,21 +62,7 @@ defmodule Mv.Membership.Member do
# user_id is NOT in accept list to prevent direct foreign key manipulation
argument :user, :map, allow_nil?: true
accept [
:first_name,
:last_name,
:email,
:birth_date,
:paid,
:phone_number,
:join_date,
:exit_date,
:notes,
:city,
:street,
:house_number,
:postal_code
]
accept @member_fields
change manage_relationship(:custom_field_values, type: :create)
@ -105,21 +95,7 @@ defmodule Mv.Membership.Member do
# user_id is NOT in accept list to prevent direct foreign key manipulation
argument :user, :map, allow_nil?: true
accept [
:first_name,
:last_name,
:email,
:birth_date,
:paid,
:phone_number,
:join_date,
:exit_date,
:notes,
:city,
:street,
:house_number,
:postal_code
]
accept @member_fields
change manage_relationship(:custom_field_values, on_match: :update, on_no_match: :create)

View file

@ -53,6 +53,7 @@ defmodule Mv.Membership do
# It's only used internally as fallback in get_settings/0
# Settings should be created via seed script
define :update_settings, action: :update
define :update_member_field_visibility, action: :update_member_field_visibility
end
end
@ -123,4 +124,37 @@ defmodule Mv.Membership do
|> Ash.Changeset.for_update(:update, attrs)
|> Ash.update(domain: __MODULE__)
end
@doc """
Updates the member field visibility configuration.
This is a specialized action for updating only the member field visibility settings.
It validates that all keys are valid member fields and all values are booleans.
## Parameters
- `settings` - The settings record to update
- `visibility_config` - A map of member field names (strings) to boolean visibility values
(e.g., `%{"street" => false, "house_number" => false}`)
## Returns
- `{:ok, updated_settings}` - Successfully updated settings
- `{:error, error}` - Validation or update error
## Examples
iex> {:ok, settings} = Mv.Membership.get_settings()
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}
"""
def update_member_field_visibility(settings, visibility_config) do
settings
|> Ash.Changeset.for_update(:update_member_field_visibility, %{
member_field_visibility: visibility_config
})
|> Ash.update(domain: __MODULE__)
end
end

View file

@ -9,6 +9,8 @@ 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`.
## Singleton Pattern
This resource uses a singleton pattern - there should only be one settings record.
@ -28,6 +30,9 @@ defmodule Mv.Membership.Setting do
# Update club name
{: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})
"""
use Ash.Resource,
domain: Mv.Membership,
@ -49,18 +54,65 @@ defmodule Mv.Membership.Setting do
# Used only as fallback in get_settings/0 if settings don't exist
# Settings should normally be created via seed script
create :create do
accept [:club_name]
accept [:club_name, :member_field_visibility]
end
update :update do
primary? true
accept [:club_name]
require_atomic? false
accept [:club_name, :member_field_visibility]
end
update :update_member_field_visibility do
description "Updates the visibility configuration for member fields in the overview"
require_atomic? false
accept [:member_field_visibility]
end
end
validations do
validate present(:club_name), on: [:create, :update]
validate string_length(:club_name, min: 1), on: [:create, :update]
# 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
# Validate all values are booleans
invalid_values =
Enum.filter(visibility, fn {_key, value} ->
not is_boolean(value)
end)
# 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
end
end,
on: [:create, :update]
end
attributes do
@ -75,6 +127,12 @@ defmodule Mv.Membership.Setting do
min_length: 1
]
attribute :member_field_visibility, :map,
allow_nil?: true,
public?: true,
description:
"Configuration for member field visibility in overview (JSONB map). Keys are member field names (atoms), values are booleans."
timestamps()
end
end

23
lib/mv/constants.ex Normal file
View file

@ -0,0 +1,23 @@
defmodule Mv.Constants do
@moduledoc """
Module for defining constants and atoms.
"""
@member_fields [
:first_name,
:last_name,
:email,
:birth_date,
:paid,
:phone_number,
:join_date,
:exit_date,
:notes,
:city,
:street,
:house_number,
:postal_code
]
def member_fields, do: @member_fields
end

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

@ -30,16 +30,23 @@ defmodule MvWeb.MemberLive.Index do
require Ash.Query
import Ash.Expr
alias Mv.Membership
alias MvWeb.MemberLive.Index.Formatter
# Prefix used in sort field names for custom fields (e.g., "custom_field_<id>")
@custom_field_prefix "custom_field_"
# Member fields that are loaded for the overview
# Uses constants from Mv.Constants to ensure consistency
# Note: :id is always included for member identification
# All member fields are loaded, but visibility is controlled via settings
@overview_fields [:id | Mv.Constants.member_fields()]
@doc """
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
@ -53,14 +60,24 @@ defmodule MvWeb.MemberLive.Index do
|> Ash.Query.sort(name: :asc)
|> Ash.read!()
# Load settings once to avoid N+1 queries
settings =
case Membership.get_settings() do
{:ok, s} -> s
# Fallback if settings can't be loaded
{:error, _} -> %{member_field_visibility: %{}}
end
socket =
socket
|> assign(:page_title, gettext("Members"))
|> assign(:query, "")
|> assign_new(:sort_field, fn -> :first_name end)
|> assign_new(:sort_order, fn -> :asc end)
|> assign(:paid_filter, nil)
|> assign(:selected_members, MapSet.new())
|> assign(:custom_fields_visible, custom_fields_visible)
|> assign(:member_fields_visible, get_visible_member_fields(settings))
# We call handle params to use the query from the URL
{:ok, socket}
@ -197,11 +214,8 @@ defmodule MvWeb.MemberLive.Index do
existing_sort_query = socket.assigns.sort_order
# Build the URL with queries
query_params = %{
"query" => q,
"sort_field" => existing_field_query,
"sort_order" => existing_sort_query
}
query_params =
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}"
@ -214,13 +228,38 @@ 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(socket.assigns.query)
# 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
# -----------------------------------------------------------------
# Handle Params from the URL
# -----------------------------------------------------------------
@doc """
Handles URL parameter changes.
Parses query parameters for search query, sort field, and sort order,
Parses query parameters for search query, sort field, sort order, and payment filter,
then loads members accordingly. This enables bookmarkable URLs and
browser back/forward navigation.
"""
@ -230,6 +269,7 @@ defmodule MvWeb.MemberLive.Index do
socket
|> maybe_update_search(params)
|> maybe_update_sort(params)
|> maybe_update_paid_filter(params)
|> load_members(params["query"])
|> prepare_dynamic_cols()
@ -321,11 +361,13 @@ defmodule MvWeb.MemberLive.Index do
field
end
query_params = %{
"query" => socket.assigns.query,
"sort_field" => field_str,
"sort_order" => Atom.to_string(order)
}
query_params =
build_query_params(
socket.assigns.query,
field_str,
Atom.to_string(order),
socket.assigns.paid_filter
)
new_path = ~p"/members?#{query_params}"
@ -336,13 +378,45 @@ defmodule MvWeb.MemberLive.Index do
)}
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
@ -358,18 +432,7 @@ defmodule MvWeb.MemberLive.Index do
query =
Mv.Membership.Member
|> Ash.Query.new()
|> Ash.Query.select([
:id,
:first_name,
:last_name,
:email,
:street,
:house_number,
:postal_code,
:city,
:phone_number,
:join_date
])
|> Ash.Query.select(@overview_fields)
# Load custom field values for visible custom fields
custom_field_ids_list = Enum.map(socket.assigns.custom_fields_visible, & &1.id)
@ -378,6 +441,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} =
@ -452,6 +518,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
@ -478,18 +562,13 @@ defmodule MvWeb.MemberLive.Index do
defp maybe_sort(query, _, _, _), do: {query, false}
# Validate that a field is sortable
# Uses member fields from constants, but excludes fields that don't make sense to sort
# (e.g., :notes is too long, :paid is boolean and not very useful for sorting)
defp valid_sort_field?(field) when is_atom(field) do
valid_fields = [
:first_name,
:last_name,
:email,
:street,
:house_number,
:postal_code,
:city,
:phone_number,
:join_date
]
# All member fields are sortable, but we exclude some that don't make sense
# :id is not in member_fields, but we don't want to sort by it anyway
non_sortable_fields = [:notes, :paid]
valid_fields = Mv.Constants.member_fields() -- non_sortable_fields
field in valid_fields or custom_field_sort?(field)
end
@ -747,6 +826,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
# -------------------------------------------------------------
@ -796,4 +898,32 @@ defmodule MvWeb.MemberLive.Index do
"#{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 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.
#
# Parameters:
# - `settings` - The settings struct loaded from the database
#
# Returns a list of atoms representing visible member field names.
#
# Fields are read from the global Constants module.
@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()
# JSONB stores keys as strings
visibility_config = settings.member_field_visibility || %{}
# Filter to only return visible fields
Enum.filter(all_fields, fn field ->
Map.get(visibility_config, Atom.to_string(field), true)
end)
end
end

View file

@ -26,12 +26,20 @@
</:actions>
</.header>
<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"
@ -89,6 +97,7 @@
</:col>
<:col
:let={member}
:if={:email in @member_fields_visible}
label={
~H"""
<.live_component
@ -106,6 +115,7 @@
</:col>
<:col
:let={member}
:if={:street in @member_fields_visible}
label={
~H"""
<.live_component
@ -123,6 +133,7 @@
</:col>
<:col
:let={member}
:if={:house_number in @member_fields_visible}
label={
~H"""
<.live_component
@ -140,6 +151,7 @@
</:col>
<:col
:let={member}
:if={:postal_code in @member_fields_visible}
label={
~H"""
<.live_component
@ -157,6 +169,7 @@
</:col>
<:col
:let={member}
:if={:city in @member_fields_visible}
label={
~H"""
<.live_component
@ -174,6 +187,7 @@
</:col>
<:col
:let={member}
:if={:phone_number in @member_fields_visible}
label={
~H"""
<.live_component
@ -191,6 +205,7 @@
</:col>
<:col
:let={member}
:if={:join_date in @member_fields_visible}
label={
~H"""
<.live_component
@ -206,6 +221,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,12 +10,12 @@ msgid ""
msgstr ""
"Language: en\n"
#: lib/mv_web/components/core_components.ex:360
#: lib/mv_web/components/core_components.ex:362
#, elixir-autogen, elixir-format
msgid "Actions"
msgstr "Aktionen"
#: lib/mv_web/live/member_live/index.html.heex:220
#: lib/mv_web/live/member_live/index.html.heex:235
#: lib/mv_web/live/user_live/index.html.heex:72
#, elixir-autogen, elixir-format
msgid "Are you sure?"
@ -28,19 +28,19 @@ msgid "Attempting to reconnect"
msgstr "Verbindung wird wiederhergestellt"
#: lib/mv_web/live/member_live/form.ex:54
#: lib/mv_web/live/member_live/index.html.heex:166
#: lib/mv_web/live/member_live/index.html.heex:173
#: lib/mv_web/live/member_live/show.ex:59
#, elixir-autogen, elixir-format
msgid "City"
msgstr "Stadt"
#: lib/mv_web/live/member_live/index.html.heex:222
#: lib/mv_web/live/member_live/index.html.heex:237
#: lib/mv_web/live/user_live/index.html.heex:74
#, elixir-autogen, elixir-format
msgid "Delete"
msgstr "Löschen"
#: lib/mv_web/live/member_live/index.html.heex:214
#: lib/mv_web/live/member_live/index.html.heex:229
#: lib/mv_web/live/user_live/form.ex:265
#: lib/mv_web/live/user_live/index.html.heex:66
#, elixir-autogen, elixir-format
@ -54,7 +54,7 @@ msgid "Edit Member"
msgstr "Mitglied bearbeiten"
#: lib/mv_web/live/member_live/form.ex:47
#: lib/mv_web/live/member_live/index.html.heex:98
#: lib/mv_web/live/member_live/index.html.heex:105
#: lib/mv_web/live/member_live/show.ex:50
#: lib/mv_web/live/user_live/form.ex:46
#: lib/mv_web/live/user_live/index.html.heex:44
@ -70,7 +70,7 @@ msgid "First Name"
msgstr "Vorname"
#: lib/mv_web/live/member_live/form.ex:51
#: lib/mv_web/live/member_live/index.html.heex:200
#: lib/mv_web/live/member_live/index.html.heex:207
#: lib/mv_web/live/member_live/show.ex:56
#, elixir-autogen, elixir-format
msgid "Join Date"
@ -87,7 +87,7 @@ msgstr "Nachname"
msgid "New Member"
msgstr "Neues Mitglied"
#: lib/mv_web/live/member_live/index.html.heex:211
#: lib/mv_web/live/member_live/index.html.heex:226
#: lib/mv_web/live/user_live/index.html.heex:63
#, elixir-autogen, elixir-format
msgid "Show"
@ -103,7 +103,7 @@ msgstr "Etwas ist schiefgelaufen!"
msgid "We can't find the internet"
msgstr "Keine Internetverbindung gefunden"
#: lib/mv_web/components/core_components.ex:78
#: lib/mv_web/components/core_components.ex:81
#, elixir-autogen, elixir-format
msgid "close"
msgstr "schließen"
@ -121,7 +121,7 @@ msgid "Exit Date"
msgstr "Austrittsdatum"
#: lib/mv_web/live/member_live/form.ex:56
#: lib/mv_web/live/member_live/index.html.heex:132
#: lib/mv_web/live/member_live/index.html.heex:139
#: lib/mv_web/live/member_live/show.ex:61
#, elixir-autogen, elixir-format
msgid "House Number"
@ -133,21 +133,24 @@ msgstr "Hausnummer"
msgid "Notes"
msgstr "Notizen"
#: lib/mv_web/live/components/payment_filter_component.ex:86
#: lib/mv_web/live/components/payment_filter_component.ex:136
#: lib/mv_web/live/member_live/form.ex:49
#: lib/mv_web/live/member_live/index.html.heex:216
#: lib/mv_web/live/member_live/show.ex:52
#, elixir-autogen, elixir-format
msgid "Paid"
msgstr "Bezahlt"
#: lib/mv_web/live/member_live/form.ex:50
#: lib/mv_web/live/member_live/index.html.heex:183
#: lib/mv_web/live/member_live/index.html.heex:190
#: lib/mv_web/live/member_live/show.ex:55
#, elixir-autogen, elixir-format
msgid "Phone Number"
msgstr "Telefonnummer"
#: lib/mv_web/live/member_live/form.ex:57
#: lib/mv_web/live/member_live/index.html.heex:149
#: lib/mv_web/live/member_live/index.html.heex:156
#: lib/mv_web/live/member_live/show.ex:62
#, elixir-autogen, elixir-format
msgid "Postal Code"
@ -168,7 +171,7 @@ msgid "Saving..."
msgstr "Speichern..."
#: lib/mv_web/live/member_live/form.ex:55
#: lib/mv_web/live/member_live/index.html.heex:115
#: lib/mv_web/live/member_live/index.html.heex:122
#: lib/mv_web/live/member_live/show.ex:60
#, elixir-autogen, elixir-format
msgid "Street"
@ -184,6 +187,7 @@ msgstr "Dieses Formular dient zur Verwaltung von Mitgliedern und deren Eigenscha
msgid "Id"
msgstr "ID"
#: lib/mv_web/live/member_live/index.html.heex:221
#: lib/mv_web/live/member_live/index/formatter.ex:61
#: lib/mv_web/live/member_live/show.ex:53
#, elixir-autogen, elixir-format
@ -200,6 +204,7 @@ msgstr "Mitglied anzeigen"
msgid "This is a member record from your database."
msgstr "Dies ist ein Mitglied aus deiner Datenbank."
#: lib/mv_web/live/member_live/index.html.heex:221
#: lib/mv_web/live/member_live/index/formatter.ex:60
#: lib/mv_web/live/member_live/show.ex:53
#, elixir-autogen, elixir-format
@ -365,12 +370,12 @@ msgstr "Profil"
msgid "Required"
msgstr "Erforderlich"
#: lib/mv_web/live/member_live/index.html.heex:55
#: lib/mv_web/live/member_live/index.html.heex:62
#, elixir-autogen, elixir-format
msgid "Select all members"
msgstr "Alle Mitglieder auswählen"
#: lib/mv_web/live/member_live/index.html.heex:69
#: lib/mv_web/live/member_live/index.html.heex:76
#, elixir-autogen, elixir-format
msgid "Select member"
msgstr "Mitglied auswählen"
@ -556,7 +561,7 @@ msgid "Toggle dark mode"
msgstr "Dunklen Modus umschalten"
#: lib/mv_web/live/components/search_bar_component.ex:15
#: lib/mv_web/live/member_live/index.html.heex:33
#: lib/mv_web/live/member_live/index.html.heex:34
#, elixir-autogen, elixir-format
msgid "Search..."
msgstr "Suchen..."
@ -572,7 +577,7 @@ msgstr "Benutzer*innen"
msgid "Click to sort"
msgstr "Klicke um zu sortieren"
#: lib/mv_web/live/member_live/index.html.heex:81
#: lib/mv_web/live/member_live/index.html.heex:88
#, elixir-autogen, elixir-format
msgid "First name"
msgstr "Vorname"
@ -782,7 +787,7 @@ msgstr "Mitglied entverknüpfen"
msgid "Unlinking scheduled"
msgstr "Entverknüpfung geplant"
#: lib/mv_web/live/member_live/index.ex:159
#: lib/mv_web/live/member_live/index.ex:149
#, elixir-autogen, elixir-format
msgid "Copied %{count} email address to clipboard"
msgid_plural "Copied %{count} email addresses to clipboard"
@ -799,12 +804,12 @@ msgstr "E-Mail-Adressen der ausgewählten Mitglieder kopieren"
msgid "Copy emails"
msgstr "E-Mails kopieren"
#: lib/mv_web/live/member_live/index.ex:142
#: lib/mv_web/live/member_live/index.ex:138
#, elixir-autogen, elixir-format
msgid "No email addresses found"
msgstr "Keine E-Mail-Adressen gefunden"
#: lib/mv_web/live/member_live/index.ex:126
#: lib/mv_web/live/member_live/index.ex:135
#, elixir-autogen, elixir-format
msgid "No members selected"
msgstr "Keine Mitglieder ausgewählt"
@ -819,7 +824,29 @@ msgstr "E-Mail-Programm mit BCC-Empfänger*innen öffnen"
msgid "Open in email program"
msgstr "Im E-Mail-Programm öffnen"
#: lib/mv_web/live/member_live/index.ex:168
#: lib/mv_web/live/member_live/index.ex:158
#, elixir-autogen, elixir-format
msgid "Tip: Paste email addresses into the BCC field for privacy compliance"
msgstr "Tipp: E-Mail-Adressen ins BCC-Feld einfügen für Datenschutzkonformität"
#: lib/mv_web/live/components/payment_filter_component.ex:72
#: lib/mv_web/live/components/payment_filter_component.ex:135
#, elixir-autogen, elixir-format
msgid "All"
msgstr "Alle"
#: lib/mv_web/live/components/payment_filter_component.ex:46
#, elixir-autogen, elixir-format
msgid "Filter by payment status"
msgstr "Nach Zahlungsstatus filtern"
#: lib/mv_web/live/components/payment_filter_component.ex:100
#: lib/mv_web/live/components/payment_filter_component.ex:137
#, elixir-autogen, elixir-format
msgid "Not paid"
msgstr "Nicht bezahlt"
#: lib/mv_web/live/components/payment_filter_component.ex:57
#, elixir-autogen, elixir-format
msgid "Payment filter"
msgstr "Zahlungsfilter"

View file

@ -11,12 +11,12 @@
msgid ""
msgstr ""
#: lib/mv_web/components/core_components.ex:360
#: lib/mv_web/components/core_components.ex:362
#, elixir-autogen, elixir-format
msgid "Actions"
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex:220
#: lib/mv_web/live/member_live/index.html.heex:235
#: lib/mv_web/live/user_live/index.html.heex:72
#, elixir-autogen, elixir-format
msgid "Are you sure?"
@ -29,19 +29,19 @@ msgid "Attempting to reconnect"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:54
#: lib/mv_web/live/member_live/index.html.heex:166
#: lib/mv_web/live/member_live/index.html.heex:173
#: lib/mv_web/live/member_live/show.ex:59
#, elixir-autogen, elixir-format
msgid "City"
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex:222
#: lib/mv_web/live/member_live/index.html.heex:237
#: lib/mv_web/live/user_live/index.html.heex:74
#, elixir-autogen, elixir-format
msgid "Delete"
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex:214
#: lib/mv_web/live/member_live/index.html.heex:229
#: lib/mv_web/live/user_live/form.ex:265
#: lib/mv_web/live/user_live/index.html.heex:66
#, elixir-autogen, elixir-format
@ -55,7 +55,7 @@ msgid "Edit Member"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:47
#: lib/mv_web/live/member_live/index.html.heex:98
#: lib/mv_web/live/member_live/index.html.heex:105
#: lib/mv_web/live/member_live/show.ex:50
#: lib/mv_web/live/user_live/form.ex:46
#: lib/mv_web/live/user_live/index.html.heex:44
@ -71,7 +71,7 @@ msgid "First Name"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:51
#: lib/mv_web/live/member_live/index.html.heex:200
#: lib/mv_web/live/member_live/index.html.heex:207
#: lib/mv_web/live/member_live/show.ex:56
#, elixir-autogen, elixir-format
msgid "Join Date"
@ -88,7 +88,7 @@ msgstr ""
msgid "New Member"
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex:211
#: lib/mv_web/live/member_live/index.html.heex:226
#: lib/mv_web/live/user_live/index.html.heex:63
#, elixir-autogen, elixir-format
msgid "Show"
@ -104,7 +104,7 @@ msgstr ""
msgid "We can't find the internet"
msgstr ""
#: lib/mv_web/components/core_components.ex:78
#: lib/mv_web/components/core_components.ex:81
#, elixir-autogen, elixir-format
msgid "close"
msgstr ""
@ -122,7 +122,7 @@ msgid "Exit Date"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:56
#: lib/mv_web/live/member_live/index.html.heex:132
#: lib/mv_web/live/member_live/index.html.heex:139
#: lib/mv_web/live/member_live/show.ex:61
#, elixir-autogen, elixir-format
msgid "House Number"
@ -134,21 +134,24 @@ msgstr ""
msgid "Notes"
msgstr ""
#: lib/mv_web/live/components/payment_filter_component.ex:86
#: lib/mv_web/live/components/payment_filter_component.ex:136
#: lib/mv_web/live/member_live/form.ex:49
#: lib/mv_web/live/member_live/index.html.heex:216
#: lib/mv_web/live/member_live/show.ex:52
#, elixir-autogen, elixir-format
msgid "Paid"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:50
#: lib/mv_web/live/member_live/index.html.heex:183
#: lib/mv_web/live/member_live/index.html.heex:190
#: lib/mv_web/live/member_live/show.ex:55
#, elixir-autogen, elixir-format
msgid "Phone Number"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:57
#: lib/mv_web/live/member_live/index.html.heex:149
#: lib/mv_web/live/member_live/index.html.heex:156
#: lib/mv_web/live/member_live/show.ex:62
#, elixir-autogen, elixir-format
msgid "Postal Code"
@ -169,7 +172,7 @@ msgid "Saving..."
msgstr ""
#: lib/mv_web/live/member_live/form.ex:55
#: lib/mv_web/live/member_live/index.html.heex:115
#: lib/mv_web/live/member_live/index.html.heex:122
#: lib/mv_web/live/member_live/show.ex:60
#, elixir-autogen, elixir-format
msgid "Street"
@ -185,6 +188,7 @@ msgstr ""
msgid "Id"
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex:221
#: lib/mv_web/live/member_live/index/formatter.ex:61
#: lib/mv_web/live/member_live/show.ex:53
#, elixir-autogen, elixir-format
@ -201,6 +205,7 @@ msgstr ""
msgid "This is a member record from your database."
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex:221
#: lib/mv_web/live/member_live/index/formatter.ex:60
#: lib/mv_web/live/member_live/show.ex:53
#, elixir-autogen, elixir-format
@ -366,12 +371,12 @@ msgstr ""
msgid "Required"
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex:55
#: lib/mv_web/live/member_live/index.html.heex:62
#, elixir-autogen, elixir-format
msgid "Select all members"
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex:69
#: lib/mv_web/live/member_live/index.html.heex:76
#, elixir-autogen, elixir-format
msgid "Select member"
msgstr ""
@ -557,7 +562,7 @@ msgid "Toggle dark mode"
msgstr ""
#: lib/mv_web/live/components/search_bar_component.ex:15
#: lib/mv_web/live/member_live/index.html.heex:33
#: lib/mv_web/live/member_live/index.html.heex:34
#, elixir-autogen, elixir-format
msgid "Search..."
msgstr ""
@ -573,7 +578,7 @@ msgstr ""
msgid "Click to sort"
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex:81
#: lib/mv_web/live/member_live/index.html.heex:88
#, elixir-autogen, elixir-format
msgid "First name"
msgstr ""
@ -783,7 +788,7 @@ msgstr ""
msgid "Unlinking scheduled"
msgstr ""
#: lib/mv_web/live/member_live/index.ex:159
#: lib/mv_web/live/member_live/index.ex:149
#, elixir-autogen, elixir-format
msgid "Copied %{count} email address to clipboard"
msgid_plural "Copied %{count} email addresses to clipboard"
@ -800,12 +805,12 @@ msgstr ""
msgid "Copy emails"
msgstr ""
#: lib/mv_web/live/member_live/index.ex:142
#: lib/mv_web/live/member_live/index.ex:138
#, elixir-autogen, elixir-format
msgid "No email addresses found"
msgstr ""
#: lib/mv_web/live/member_live/index.ex:126
#: lib/mv_web/live/member_live/index.ex:135
#, elixir-autogen, elixir-format
msgid "No members selected"
msgstr ""
@ -820,7 +825,29 @@ msgstr ""
msgid "Open in email program"
msgstr ""
#: lib/mv_web/live/member_live/index.ex:168
#: lib/mv_web/live/member_live/index.ex:158
#, elixir-autogen, elixir-format
msgid "Tip: Paste email addresses into the BCC field for privacy compliance"
msgstr ""
#: lib/mv_web/live/components/payment_filter_component.ex:72
#: lib/mv_web/live/components/payment_filter_component.ex:135
#, elixir-autogen, elixir-format
msgid "All"
msgstr ""
#: lib/mv_web/live/components/payment_filter_component.ex:46
#, elixir-autogen, elixir-format
msgid "Filter by payment status"
msgstr ""
#: lib/mv_web/live/components/payment_filter_component.ex:100
#: lib/mv_web/live/components/payment_filter_component.ex:137
#, elixir-autogen, elixir-format
msgid "Not paid"
msgstr ""
#: lib/mv_web/live/components/payment_filter_component.ex:57
#, elixir-autogen, elixir-format
msgid "Payment filter"
msgstr ""

View file

@ -11,12 +11,12 @@ msgstr ""
"Language: en\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
#: lib/mv_web/components/core_components.ex:360
#: lib/mv_web/components/core_components.ex:362
#, elixir-autogen, elixir-format
msgid "Actions"
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex:220
#: lib/mv_web/live/member_live/index.html.heex:235
#: lib/mv_web/live/user_live/index.html.heex:72
#, elixir-autogen, elixir-format
msgid "Are you sure?"
@ -29,19 +29,19 @@ msgid "Attempting to reconnect"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:54
#: lib/mv_web/live/member_live/index.html.heex:166
#: lib/mv_web/live/member_live/index.html.heex:173
#: lib/mv_web/live/member_live/show.ex:59
#, elixir-autogen, elixir-format
msgid "City"
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex:222
#: lib/mv_web/live/member_live/index.html.heex:237
#: lib/mv_web/live/user_live/index.html.heex:74
#, elixir-autogen, elixir-format
msgid "Delete"
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex:214
#: lib/mv_web/live/member_live/index.html.heex:229
#: lib/mv_web/live/user_live/form.ex:265
#: lib/mv_web/live/user_live/index.html.heex:66
#, elixir-autogen, elixir-format
@ -55,7 +55,7 @@ msgid "Edit Member"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:47
#: lib/mv_web/live/member_live/index.html.heex:98
#: lib/mv_web/live/member_live/index.html.heex:105
#: lib/mv_web/live/member_live/show.ex:50
#: lib/mv_web/live/user_live/form.ex:46
#: lib/mv_web/live/user_live/index.html.heex:44
@ -71,7 +71,7 @@ msgid "First Name"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:51
#: lib/mv_web/live/member_live/index.html.heex:200
#: lib/mv_web/live/member_live/index.html.heex:207
#: lib/mv_web/live/member_live/show.ex:56
#, elixir-autogen, elixir-format
msgid "Join Date"
@ -88,7 +88,7 @@ msgstr ""
msgid "New Member"
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex:211
#: lib/mv_web/live/member_live/index.html.heex:226
#: lib/mv_web/live/user_live/index.html.heex:63
#, elixir-autogen, elixir-format
msgid "Show"
@ -104,7 +104,7 @@ msgstr ""
msgid "We can't find the internet"
msgstr ""
#: lib/mv_web/components/core_components.ex:78
#: lib/mv_web/components/core_components.ex:81
#, elixir-autogen, elixir-format
msgid "close"
msgstr ""
@ -122,7 +122,7 @@ msgid "Exit Date"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:56
#: lib/mv_web/live/member_live/index.html.heex:132
#: lib/mv_web/live/member_live/index.html.heex:139
#: lib/mv_web/live/member_live/show.ex:61
#, elixir-autogen, elixir-format
msgid "House Number"
@ -134,21 +134,24 @@ msgstr ""
msgid "Notes"
msgstr ""
#: lib/mv_web/live/components/payment_filter_component.ex:86
#: lib/mv_web/live/components/payment_filter_component.ex:136
#: lib/mv_web/live/member_live/form.ex:49
#: lib/mv_web/live/member_live/index.html.heex:216
#: lib/mv_web/live/member_live/show.ex:52
#, elixir-autogen, elixir-format
msgid "Paid"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:50
#: lib/mv_web/live/member_live/index.html.heex:183
#: lib/mv_web/live/member_live/index.html.heex:190
#: lib/mv_web/live/member_live/show.ex:55
#, elixir-autogen, elixir-format
msgid "Phone Number"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:57
#: lib/mv_web/live/member_live/index.html.heex:149
#: lib/mv_web/live/member_live/index.html.heex:156
#: lib/mv_web/live/member_live/show.ex:62
#, elixir-autogen, elixir-format
msgid "Postal Code"
@ -169,7 +172,7 @@ msgid "Saving..."
msgstr ""
#: lib/mv_web/live/member_live/form.ex:55
#: lib/mv_web/live/member_live/index.html.heex:115
#: lib/mv_web/live/member_live/index.html.heex:122
#: lib/mv_web/live/member_live/show.ex:60
#, elixir-autogen, elixir-format
msgid "Street"
@ -185,6 +188,7 @@ msgstr ""
msgid "Id"
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex:221
#: lib/mv_web/live/member_live/index/formatter.ex:61
#: lib/mv_web/live/member_live/show.ex:53
#, elixir-autogen, elixir-format
@ -201,6 +205,7 @@ msgstr ""
msgid "This is a member record from your database."
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex:221
#: lib/mv_web/live/member_live/index/formatter.ex:60
#: lib/mv_web/live/member_live/show.ex:53
#, elixir-autogen, elixir-format
@ -366,12 +371,12 @@ msgstr ""
msgid "Required"
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex:55
#: lib/mv_web/live/member_live/index.html.heex:62
#, elixir-autogen, elixir-format
msgid "Select all members"
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex:69
#: lib/mv_web/live/member_live/index.html.heex:76
#, elixir-autogen, elixir-format
msgid "Select member"
msgstr ""
@ -557,7 +562,7 @@ msgid "Toggle dark mode"
msgstr ""
#: lib/mv_web/live/components/search_bar_component.ex:15
#: lib/mv_web/live/member_live/index.html.heex:33
#: lib/mv_web/live/member_live/index.html.heex:34
#, elixir-autogen, elixir-format
msgid "Search..."
msgstr ""
@ -573,7 +578,7 @@ msgstr ""
msgid "Click to sort"
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex:81
#: lib/mv_web/live/member_live/index.html.heex:88
#, elixir-autogen, elixir-format, fuzzy
msgid "First name"
msgstr ""
@ -783,7 +788,7 @@ msgstr ""
msgid "Unlinking scheduled"
msgstr ""
#: lib/mv_web/live/member_live/index.ex:159
#: lib/mv_web/live/member_live/index.ex:149
#, elixir-autogen, elixir-format
msgid "Copied %{count} email address to clipboard"
msgid_plural "Copied %{count} email addresses to clipboard"
@ -800,12 +805,12 @@ msgstr ""
msgid "Copy emails"
msgstr ""
#: lib/mv_web/live/member_live/index.ex:142
#: lib/mv_web/live/member_live/index.ex:138
#, elixir-autogen, elixir-format
msgid "No email addresses found"
msgstr ""
#: lib/mv_web/live/member_live/index.ex:126
#: lib/mv_web/live/member_live/index.ex:135
#, elixir-autogen, elixir-format, fuzzy
msgid "No members selected"
msgstr ""
@ -820,7 +825,29 @@ msgstr ""
msgid "Open in email program"
msgstr ""
#: lib/mv_web/live/member_live/index.ex:168
#: lib/mv_web/live/member_live/index.ex:158
#, elixir-autogen, elixir-format
msgid "Tip: Paste email addresses into the BCC field for privacy compliance"
msgstr ""
#: lib/mv_web/live/components/payment_filter_component.ex:72
#: lib/mv_web/live/components/payment_filter_component.ex:135
#, elixir-autogen, elixir-format
msgid "All"
msgstr ""
#: lib/mv_web/live/components/payment_filter_component.ex:46
#, elixir-autogen, elixir-format
msgid "Filter by payment status"
msgstr ""
#: lib/mv_web/live/components/payment_filter_component.ex:100
#: lib/mv_web/live/components/payment_filter_component.ex:137
#, elixir-autogen, elixir-format
msgid "Not paid"
msgstr ""
#: lib/mv_web/live/components/payment_filter_component.ex:57
#, elixir-autogen, elixir-format
msgid "Payment filter"
msgstr ""

View file

@ -0,0 +1,21 @@
defmodule Mv.Repo.Migrations.AddMemberFieldVisibilityToSettings do
@moduledoc """
Updates resources based on their most recent snapshots.
This file was autogenerated with `mix ash_postgres.generate_migrations`
"""
use Ecto.Migration
def up do
alter table(:settings) do
add :member_field_visibility, :map
end
end
def down do
alter table(:settings) do
remove :member_field_visibility
end
end
end

View file

@ -0,0 +1,144 @@
{
"attributes": [
{
"allow_nil?": false,
"default": "fragment(\"gen_random_uuid()\")",
"generated?": false,
"precision": null,
"primary_key?": true,
"references": null,
"scale": null,
"size": null,
"source": "id",
"type": "uuid"
},
{
"allow_nil?": false,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "name",
"type": "text"
},
{
"allow_nil?": false,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "slug",
"type": "text"
},
{
"allow_nil?": false,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "value_type",
"type": "text"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "description",
"type": "text"
},
{
"allow_nil?": false,
"default": "false",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "immutable",
"type": "boolean"
},
{
"allow_nil?": false,
"default": "false",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "required",
"type": "boolean"
},
{
"allow_nil?": false,
"default": "true",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "show_in_overview",
"type": "boolean"
}
],
"base_filter": null,
"check_constraints": [],
"custom_indexes": [],
"custom_statements": [],
"has_create_action": true,
"hash": "D31160C95D3D32BA715D493DE2D2B8D6572E0EC68AE14B928D99975BC8A81542",
"identities": [
{
"all_tenants?": false,
"base_filter": null,
"index_name": "custom_fields_unique_name_index",
"keys": [
{
"type": "atom",
"value": "name"
}
],
"name": "unique_name",
"nils_distinct?": true,
"where": null
},
{
"all_tenants?": false,
"base_filter": null,
"index_name": "custom_fields_unique_slug_index",
"keys": [
{
"type": "atom",
"value": "slug"
}
],
"name": "unique_slug",
"nils_distinct?": true,
"where": null
}
],
"multitenancy": {
"attribute": null,
"global": null,
"strategy": null
},
"repo": "Elixir.Mv.Repo",
"schema": null,
"table": "custom_fields"
}

View file

@ -0,0 +1,79 @@
{
"attributes": [
{
"allow_nil?": false,
"default": "fragment(\"gen_random_uuid()\")",
"generated?": false,
"precision": null,
"primary_key?": true,
"references": null,
"scale": null,
"size": null,
"source": "id",
"type": "uuid"
},
{
"allow_nil?": false,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "club_name",
"type": "text"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "member_field_visibility",
"type": "map"
},
{
"allow_nil?": false,
"default": "fragment(\"(now() AT TIME ZONE 'utc')\")",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "inserted_at",
"type": "utc_datetime_usec"
},
{
"allow_nil?": false,
"default": "fragment(\"(now() AT TIME ZONE 'utc')\")",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "updated_at",
"type": "utc_datetime_usec"
}
],
"base_filter": null,
"check_constraints": [],
"custom_indexes": [],
"custom_statements": [],
"has_create_action": true,
"hash": "F2823210AA9E6476074A218375F64CD80E7F9E04EECC4E94D4C7FD31A773C016",
"identities": [],
"multitenancy": {
"attribute": null,
"global": null,
"strategy": null
},
"repo": "Elixir.Mv.Repo",
"schema": null,
"table": "settings"
}

View file

@ -0,0 +1,14 @@
defmodule Mv.Membership.MemberFieldVisibilityTest do
@moduledoc """
Tests for member field visibility configuration.
Tests cover:
- Member fields are visible by default (show_in_overview: true)
- Member fields can be hidden (show_in_overview: false)
- Checking if a specific field is visible
- Configuration is stored in Settings resource
"""
use Mv.DataCase, async: true
alias Mv.Membership.Member
end

View file

@ -0,0 +1,183 @@
defmodule MvWeb.Components.PaymentFilterComponentTest do
@moduledoc """
Unit tests for the PaymentFilterComponent.
Tests cover:
- Rendering in all 3 filter states (nil, :paid, :not_paid)
- Event emission when selecting options
- ARIA attributes for accessibility
- Dropdown open/close behavior
"""
# async: false to prevent PostgreSQL deadlocks when running LiveView tests against DB
use MvWeb.ConnCase, async: false
import Phoenix.LiveViewTest
describe "rendering" do
test "renders with no filter active (nil)", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Should show "All" text and no badge
assert has_element?(view, "#payment-filter")
refute has_element?(view, "#payment-filter .badge")
end
test "renders with paid filter active", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members?paid_filter=paid")
# Should show badge when filter is active
assert has_element?(view, "#payment-filter .badge")
end
test "renders with not_paid filter active", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members?paid_filter=not_paid")
# Should show badge when filter is active
assert has_element?(view, "#payment-filter .badge")
end
end
describe "dropdown behavior" do
test "dropdown opens on button click", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Initially dropdown is closed
refute has_element?(view, "#payment-filter ul[role='menu']")
# Click to open
view
|> element("#payment-filter button[aria-haspopup='true']")
|> render_click()
# Dropdown should be visible
assert has_element?(view, "#payment-filter ul[role='menu']")
end
test "dropdown closes after selecting an option", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Open dropdown
view
|> element("#payment-filter button[aria-haspopup='true']")
|> render_click()
assert has_element?(view, "#payment-filter ul[role='menu']")
# Select an option - this should close the dropdown
view
|> element("#payment-filter button[phx-value-filter='paid']")
|> render_click()
# After selection, dropdown should be closed
# Note: The dropdown closes via assign, which is reflected in the next render
refute has_element?(view, "#payment-filter ul[role='menu']")
end
end
describe "filter selection" do
test "selecting 'All' clears the filter and updates URL", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members?paid_filter=paid")
# Open dropdown
view
|> element("#payment-filter button[aria-haspopup='true']")
|> render_click()
# Select "All" option
view
|> element("#payment-filter button[phx-value-filter='']")
|> render_click()
# URL should not contain paid_filter param - wait for patch
assert_patch(view)
end
test "selecting 'Paid' sets the filter and updates URL", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Open dropdown
view
|> element("#payment-filter button[aria-haspopup='true']")
|> render_click()
# Select "Paid" option
view
|> element("#payment-filter button[phx-value-filter='paid']")
|> render_click()
# Wait for patch and check URL contains paid_filter=paid
path = assert_patch(view)
assert path =~ "paid_filter=paid"
end
test "selecting 'Not paid' sets the filter and updates URL", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Open dropdown
view
|> element("#payment-filter button[aria-haspopup='true']")
|> render_click()
# Select "Not paid" option
view
|> element("#payment-filter button[phx-value-filter='not_paid']")
|> render_click()
# Wait for patch and check URL contains paid_filter=not_paid
path = assert_patch(view)
assert path =~ "paid_filter=not_paid"
end
end
describe "accessibility" do
test "has correct ARIA attributes", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, html} = live(conn, "/members")
# Main button should have aria-haspopup and aria-expanded
assert html =~ ~s(aria-haspopup="true")
assert html =~ ~s(aria-expanded="false")
assert html =~ ~s(aria-label=)
# Open dropdown
view
|> element("#payment-filter button[aria-haspopup='true']")
|> render_click()
html = render(view)
# Check aria-expanded is now true
assert html =~ ~s(aria-expanded="true")
# Menu should have role="menu"
assert html =~ ~s(role="menu")
# Options should have role="menuitemradio"
assert html =~ ~s(role="menuitemradio")
end
test "has aria-checked on selected option", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members?paid_filter=paid")
# Open dropdown
view
|> element("#payment-filter button[aria-haspopup='true']")
|> render_click()
html = render(view)
# "Paid" option should have aria-checked="true"
# Check both possible orderings of attributes
assert html =~ "aria-checked=\"true\"" and html =~ "phx-value-filter=\"paid\""
end
end
end

View file

@ -9,7 +9,8 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsDisplayTest do
- Custom field values are correctly formatted for different types
- Members without custom field values show empty cell or "-"
"""
use MvWeb.ConnCase, async: true
# async: false to prevent PostgreSQL deadlocks when creating members and custom fields
use MvWeb.ConnCase, async: false
import Phoenix.LiveViewTest
require Ash.Query

View file

@ -0,0 +1,64 @@
defmodule MvWeb.MemberLive.IndexMemberFieldsDisplayTest do
use MvWeb.ConnCase, async: true
import Phoenix.LiveViewTest
require Ash.Query
alias Mv.Membership.Member
setup do
{:ok, member1} =
Member
|> Ash.Changeset.for_create(:create_member, %{
first_name: "Alice",
last_name: "Anderson",
email: "alice@example.com",
street: "Main Street",
house_number: "123",
postal_code: "12345",
city: "Berlin",
phone_number: "+49123456789",
join_date: ~D[2020-01-15]
})
|> Ash.create()
{:ok, member2} =
Member
|> Ash.Changeset.for_create(:create_member, %{
first_name: "Bob",
last_name: "Brown",
email: "bob@example.com"
})
|> Ash.create()
%{
member1: member1,
member2: member2
}
end
test "shows multiple members correctly", %{conn: conn, member1: m1, member2: m2} do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members")
for m <- [m1, m2], field <- [m.first_name, m.last_name, m.email] do
assert html =~ field
end
end
test "respects show_in_overview config", %{conn: conn, member1: m} do
{:ok, settings} = Mv.Membership.get_settings()
fields_to_hide = [:street, :house_number]
{:ok, _} =
Mv.Membership.update_settings(settings, %{
member_field_visibility: Map.new(fields_to_hide, &{Atom.to_string(&1), false})
})
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members")
assert html =~ "Email"
assert html =~ m.email
refute html =~ m.street
end
end

View file

@ -469,4 +469,221 @@ defmodule MvWeb.MemberLive.IndexTest do
assert has_element?(view, "#flash-group")
end
end
describe "payment filter integration" do
setup do
# Create members with different payment status
# Use unique names that won't appear elsewhere in the HTML
{:ok, paid_member} =
Mv.Membership.create_member(%{
first_name: "Zahler",
last_name: "Mitglied",
email: "zahler@example.com",
paid: true
})
{:ok, unpaid_member} =
Mv.Membership.create_member(%{
first_name: "Nichtzahler",
last_name: "Mitglied",
email: "nichtzahler@example.com",
paid: false
})
{:ok, nil_paid_member} =
Mv.Membership.create_member(%{
first_name: "Unbestimmt",
last_name: "Mitglied",
email: "unbestimmt@example.com"
# paid is nil by default
})
%{paid_member: paid_member, unpaid_member: unpaid_member, nil_paid_member: nil_paid_member}
end
test "filter shows all members when no filter is active", %{
conn: conn,
paid_member: paid_member,
unpaid_member: unpaid_member,
nil_paid_member: nil_paid_member
} do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members")
assert html =~ paid_member.first_name
assert html =~ unpaid_member.first_name
assert html =~ nil_paid_member.first_name
end
test "filter shows only paid members when paid filter is active", %{
conn: conn,
paid_member: paid_member,
unpaid_member: unpaid_member,
nil_paid_member: nil_paid_member
} do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members?paid_filter=paid")
assert html =~ paid_member.first_name
refute html =~ unpaid_member.first_name
refute html =~ nil_paid_member.first_name
end
test "filter shows only unpaid members (including nil) when not_paid filter is active", %{
conn: conn,
paid_member: paid_member,
unpaid_member: unpaid_member,
nil_paid_member: nil_paid_member
} do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members?paid_filter=not_paid")
refute html =~ paid_member.first_name
assert html =~ unpaid_member.first_name
assert html =~ nil_paid_member.first_name
end
test "filter combines with search query (AND)", %{
conn: conn,
paid_member: paid_member
} do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members?query=Zahler&paid_filter=paid")
assert html =~ paid_member.first_name
end
test "filter combines with sorting", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} =
live(conn, "/members?paid_filter=paid&sort_field=first_name&sort_order=asc")
# Click on email sort header
view
|> element("[data-testid='email']")
|> render_click()
# Filter should be preserved in URL
path = assert_patch(view)
assert path =~ "paid_filter=paid"
assert path =~ "sort_field=email"
end
test "URL parameter paid_filter is set when selecting filter", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Open filter dropdown
view
|> element("#payment-filter button[aria-haspopup='true']")
|> render_click()
# Select "Paid" option
view
|> element("#payment-filter button[phx-value-filter='paid']")
|> render_click()
path = assert_patch(view)
assert path =~ "paid_filter=paid"
end
test "URL parameter is correctly read on page load", %{
conn: conn,
paid_member: paid_member
} do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members?paid_filter=paid")
# Only paid member should be visible
assert html =~ paid_member.first_name
# Filter badge should be visible
assert html =~ "badge"
end
test "invalid URL parameter is ignored", %{
conn: conn,
paid_member: paid_member,
unpaid_member: unpaid_member
} do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members?paid_filter=invalid_value")
# All members should be visible (filter not applied)
assert html =~ paid_member.first_name
assert html =~ unpaid_member.first_name
end
test "search maintains filter state", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members?paid_filter=paid")
# Perform search
view
|> element("[data-testid='search-input']")
|> render_change(%{"query" => "test"})
# Filter state should be maintained in URL
path = assert_patch(view)
assert path =~ "paid_filter=paid"
end
end
describe "paid column in table" do
setup do
{:ok, paid_member} =
Mv.Membership.create_member(%{
first_name: "Paid",
last_name: "Member",
email: "paid.column@example.com",
paid: true
})
{:ok, unpaid_member} =
Mv.Membership.create_member(%{
first_name: "Unpaid",
last_name: "Member",
email: "unpaid.column@example.com",
paid: false
})
%{paid_member: paid_member, unpaid_member: unpaid_member}
end
test "paid column shows green badge for paid members", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members")
# Check for success badge (green)
assert html =~ "badge-success"
end
test "paid column shows red badge for unpaid members", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members")
# Check for error badge (red)
assert html =~ "badge-error"
end
test "paid column shows 'Yes' for paid members", %{conn: conn} do
conn = conn_with_oidc_user(conn)
Gettext.put_locale(MvWeb.Gettext, "en")
{:ok, _view, html} = live(conn, "/members")
# The table should contain "Yes" text inside badge
assert html =~ "badge-success"
assert html =~ "Yes"
end
test "paid column shows 'No' for unpaid members", %{conn: conn} do
conn = conn_with_oidc_user(conn)
Gettext.put_locale(MvWeb.Gettext, "en")
{:ok, _view, html} = live(conn, "/members")
# The table should contain "No" text inside badge
assert html =~ "badge-error"
assert html =~ "No"
end
end
end