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
11 changed files with 503 additions and 56 deletions

View file

@ -42,6 +42,10 @@ defmodule Mv.Membership.Member do
@member_search_limit 10 @member_search_limit 10
@default_similarity_threshold 0.2 @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 postgres do
table "members" table "members"
repo Mv.Repo 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 # user_id is NOT in accept list to prevent direct foreign key manipulation
argument :user, :map, allow_nil?: true argument :user, :map, allow_nil?: true
accept [ accept @member_fields
:first_name,
:last_name,
:email,
:birth_date,
:paid,
:phone_number,
:join_date,
:exit_date,
:notes,
:city,
:street,
:house_number,
:postal_code
]
change manage_relationship(:custom_field_values, type: :create) 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 # user_id is NOT in accept list to prevent direct foreign key manipulation
argument :user, :map, allow_nil?: true argument :user, :map, allow_nil?: true
accept [ accept @member_fields
:first_name,
:last_name,
:email,
:birth_date,
:paid,
:phone_number,
:join_date,
:exit_date,
:notes,
:city,
:street,
:house_number,
:postal_code
]
change manage_relationship(:custom_field_values, on_match: :update, on_no_match: :create) 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 # It's only used internally as fallback in get_settings/0
# Settings should be created via seed script # Settings should be created via seed script
define :update_settings, action: :update define :update_settings, action: :update
define :update_member_field_visibility, action: :update_member_field_visibility
end end
end end
@ -123,4 +124,37 @@ defmodule Mv.Membership do
|> Ash.Changeset.for_update(:update, attrs) |> Ash.Changeset.for_update(:update, attrs)
|> Ash.update(domain: __MODULE__) |> Ash.update(domain: __MODULE__)
end 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 end

View file

@ -9,6 +9,8 @@ defmodule Mv.Membership.Setting do
## Attributes ## Attributes
- `club_name` - The name of the association/club (required, cannot be empty) - `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 ## Singleton Pattern
This resource uses a singleton pattern - there should only be one settings record. 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 # Update club name
{:ok, updated} = Mv.Membership.update_settings(settings, %{club_name: "New 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, use Ash.Resource,
domain: Mv.Membership, 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 # Used only as fallback in get_settings/0 if settings don't exist
# Settings should normally be created via seed script # Settings should normally be created via seed script
create :create do create :create do
accept [:club_name] accept [:club_name, :member_field_visibility]
end end
update :update do update :update do
primary? true 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
end end
validations do validations do
validate present(:club_name), on: [:create, :update] validate present(:club_name), on: [:create, :update]
validate string_length(:club_name, min: 1), 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 end
attributes do attributes do
@ -75,6 +127,12 @@ defmodule Mv.Membership.Setting do
min_length: 1 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() timestamps()
end end
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

@ -30,11 +30,18 @@ defmodule MvWeb.MemberLive.Index do
require Ash.Query require Ash.Query
import Ash.Expr import Ash.Expr
alias Mv.Membership
alias MvWeb.MemberLive.Index.Formatter alias MvWeb.MemberLive.Index.Formatter
# Prefix used in sort field names for custom fields (e.g., "custom_field_<id>") # Prefix used in sort field names for custom fields (e.g., "custom_field_<id>")
@custom_field_prefix "custom_field_" @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 """ @doc """
Initializes the LiveView state. Initializes the LiveView state.
@ -53,6 +60,14 @@ defmodule MvWeb.MemberLive.Index do
|> Ash.Query.sort(name: :asc) |> Ash.Query.sort(name: :asc)
|> Ash.read!() |> 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 =
socket socket
|> assign(:page_title, gettext("Members")) |> assign(:page_title, gettext("Members"))
@ -62,6 +77,7 @@ defmodule MvWeb.MemberLive.Index do
|> assign(:paid_filter, nil) |> assign(:paid_filter, nil)
|> assign(:selected_members, MapSet.new()) |> assign(:selected_members, MapSet.new())
|> assign(:custom_fields_visible, custom_fields_visible) |> 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 # We call handle params to use the query from the URL
{:ok, socket} {:ok, socket}
@ -416,19 +432,7 @@ defmodule MvWeb.MemberLive.Index do
query = query =
Mv.Membership.Member Mv.Membership.Member
|> Ash.Query.new() |> Ash.Query.new()
|> Ash.Query.select([ |> Ash.Query.select(@overview_fields)
:id,
:first_name,
:last_name,
:email,
:street,
:house_number,
:postal_code,
:city,
:phone_number,
:join_date,
:paid
])
# Load custom field values for visible custom fields # Load custom field values for visible custom fields
custom_field_ids_list = Enum.map(socket.assigns.custom_fields_visible, & &1.id) custom_field_ids_list = Enum.map(socket.assigns.custom_fields_visible, & &1.id)
@ -558,18 +562,13 @@ defmodule MvWeb.MemberLive.Index do
defp maybe_sort(query, _, _, _), do: {query, false} defp maybe_sort(query, _, _, _), do: {query, false}
# Validate that a field is sortable # 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 defp valid_sort_field?(field) when is_atom(field) do
valid_fields = [ # All member fields are sortable, but we exclude some that don't make sense
:first_name, # :id is not in member_fields, but we don't want to sort by it anyway
:last_name, non_sortable_fields = [:notes, :paid]
:email, valid_fields = Mv.Constants.member_fields() -- non_sortable_fields
:street,
:house_number,
:postal_code,
:city,
:phone_number,
:join_date
]
field in valid_fields or custom_field_sort?(field) field in valid_fields or custom_field_sort?(field)
end end
@ -899,4 +898,32 @@ defmodule MvWeb.MemberLive.Index do
"#{name} <#{member.email}>" "#{name} <#{member.email}>"
end end
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 end

View file

@ -97,6 +97,7 @@
</:col> </:col>
<:col <:col
:let={member} :let={member}
:if={:email in @member_fields_visible}
label={ label={
~H""" ~H"""
<.live_component <.live_component
@ -114,6 +115,7 @@
</:col> </:col>
<:col <:col
:let={member} :let={member}
:if={:street in @member_fields_visible}
label={ label={
~H""" ~H"""
<.live_component <.live_component
@ -131,6 +133,7 @@
</:col> </:col>
<:col <:col
:let={member} :let={member}
:if={:house_number in @member_fields_visible}
label={ label={
~H""" ~H"""
<.live_component <.live_component
@ -148,6 +151,7 @@
</:col> </:col>
<:col <:col
:let={member} :let={member}
:if={:postal_code in @member_fields_visible}
label={ label={
~H""" ~H"""
<.live_component <.live_component
@ -165,6 +169,7 @@
</:col> </:col>
<:col <:col
:let={member} :let={member}
:if={:city in @member_fields_visible}
label={ label={
~H""" ~H"""
<.live_component <.live_component
@ -182,6 +187,7 @@
</:col> </:col>
<:col <:col
:let={member} :let={member}
:if={:phone_number in @member_fields_visible}
label={ label={
~H""" ~H"""
<.live_component <.live_component
@ -199,6 +205,7 @@
</:col> </:col>
<:col <:col
:let={member} :let={member}
:if={:join_date in @member_fields_visible}
label={ label={
~H""" ~H"""
<.live_component <.live_component

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,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