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

This commit is contained in:
carla 2025-12-08 11:43:54 +01:00
commit 1b06f885bf
60 changed files with 8281 additions and 1644 deletions

View file

@ -11,4 +11,70 @@ defmodule Mv.Membership.MemberFieldVisibilityTest do
use Mv.DataCase, async: true
alias Mv.Membership.Member
describe "show_in_overview?/1" do
test "returns true for all member fields by default" do
# When no settings exist or member_field_visibility is not configured
# Test with fields from constants
member_fields = Mv.Constants.member_fields()
Enum.each(member_fields, fn field ->
assert Member.show_in_overview?(field) == true,
"Field #{field} should be visible by default"
end)
end
test "returns false for fields with show_in_overview: false in settings" do
# Get or create settings
{:ok, settings} = Mv.Membership.get_settings()
# Use a field that exists in member fields
member_fields = Mv.Constants.member_fields()
field_to_hide = List.first(member_fields)
field_to_show = List.last(member_fields)
# Update settings to hide a field (use string keys for JSONB)
{:ok, _updated_settings} =
Mv.Membership.update_settings(settings, %{
member_field_visibility: %{Atom.to_string(field_to_hide) => false}
})
# JSONB may convert atom keys to string keys, so we check via show_in_overview? instead
assert Member.show_in_overview?(field_to_hide) == false
assert Member.show_in_overview?(field_to_show) == true
end
test "returns true for non-configured fields (default)" do
# Get or create settings
{:ok, settings} = Mv.Membership.get_settings()
# Use fields that exist in member fields
member_fields = Mv.Constants.member_fields()
fields_to_hide = Enum.take(member_fields, 2)
fields_to_show = Enum.take(member_fields, -2)
# Update settings to hide some fields (use string keys for JSONB)
visibility_config =
Enum.reduce(fields_to_hide, %{}, fn field, acc ->
Map.put(acc, Atom.to_string(field), false)
end)
{:ok, _updated_settings} =
Mv.Membership.update_settings(settings, %{
member_field_visibility: visibility_config
})
# Hidden fields should be false
Enum.each(fields_to_hide, fn field ->
assert Member.show_in_overview?(field) == false,
"Field #{field} should be hidden"
end)
# Unconfigured fields should still be true (default)
Enum.each(fields_to_show, fn field ->
assert Member.show_in_overview?(field) == true,
"Field #{field} should be visible by default"
end)
end
end
end

View file

@ -0,0 +1,21 @@
defmodule MvWeb.Components.FieldVisibilityDropdownComponentTest do
use MvWeb.ConnCase, async: true
import Phoenix.LiveViewTest
describe "field visibility dropdown in member view" do
test "renders and toggles visibility", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, ~p"/members")
# Renders Dropdown
assert has_element?(view, "[data-testid='dropdown-menu']")
# Opens Dropdown
view |> element("[data-testid='dropdown-button']") |> render_click()
assert has_element?(view, "#field-visibility-menu")
assert has_element?(view, "button[phx-click='select_item'][phx-value-item='email']")
assert has_element?(view, "button[phx-click='select_all']")
assert has_element?(view, "button[phx-click='select_none']")
end
end
end

View file

@ -150,35 +150,27 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
assert has_element?(view, "[data-testid='email'] .opacity-40")
end
test "icon distribution is correct for all fields", %{conn: conn} do
test "icon distribution shows exactly one active sort icon", %{conn: conn} do
conn = conn_with_oidc_user(conn)
# Test neutral state - all fields except first name (default) should show neutral icons
# Test neutral state - only one field should have active sort icon
{:ok, _view, html_neutral} = live(conn, "/members")
# Count neutral icons (should be 7 - one for each field)
neutral_count =
html_neutral |> String.split("hero-chevron-up-down") |> length() |> Kernel.-(1)
assert neutral_count == 7
# Count active icons (should be 1)
# Count active icons (should be exactly 1 - ascending for default sort field)
up_count = html_neutral |> String.split("hero-chevron-up ") |> length() |> Kernel.-(1)
down_count = html_neutral |> String.split("hero-chevron-down ") |> length() |> Kernel.-(1)
assert up_count == 1
assert down_count == 0
# Test ascending state - one field active, others neutral
{:ok, _view, html_asc} = live(conn, "/members?sort_field=first_name&sort_order=asc")
assert up_count == 1, "Expected exactly 1 ascending icon, got #{up_count}"
assert down_count == 0, "Expected 0 descending icons, got #{down_count}"
# Should have exactly 1 ascending icon and 7 neutral icons
up_count = html_asc |> String.split("hero-chevron-up ") |> length() |> Kernel.-(1)
neutral_count = html_asc |> String.split("hero-chevron-up-down") |> length() |> Kernel.-(1)
down_count = html_asc |> String.split("hero-chevron-down ") |> length() |> Kernel.-(1)
# Test descending state
{:ok, _view, html_desc} = live(conn, "/members?sort_field=first_name&sort_order=desc")
assert up_count == 1
assert neutral_count == 7
assert down_count == 0
up_count = html_desc |> String.split("hero-chevron-up ") |> length() |> Kernel.-(1)
down_count = html_desc |> String.split("hero-chevron-down ") |> length() |> Kernel.-(1)
assert up_count == 0, "Expected 0 ascending icons, got #{up_count}"
assert down_count == 1, "Expected exactly 1 descending icon, got #{down_count}"
end
end

View file

@ -1,6 +1,7 @@
defmodule MvWeb.CustomFieldLive.DeletionTest do
@moduledoc """
Tests for CustomFieldLive.Index deletion modal and slug confirmation.
Tests for CustomFieldLive.IndexComponent deletion modal and slug confirmation.
Tests the custom field management component embedded in the settings page.
Tests cover:
- Opening deletion confirmation modal
@ -39,11 +40,11 @@ defmodule MvWeb.CustomFieldLive.DeletionTest do
# Create custom field value
create_custom_field_value(member, custom_field, "test")
{:ok, view, _html} = live(conn, ~p"/custom_fields")
{:ok, view, _html} = live(conn, ~p"/settings")
# Click delete button
# Click delete button - find the delete link within the component
view
|> element("a", "Delete")
|> element("#custom-fields-component a", "Delete")
|> render_click()
# Modal should be visible
@ -65,10 +66,10 @@ defmodule MvWeb.CustomFieldLive.DeletionTest do
create_custom_field_value(member1, custom_field, "test1")
create_custom_field_value(member2, custom_field, "test2")
{:ok, view, _html} = live(conn, ~p"/custom_fields")
{:ok, view, _html} = live(conn, ~p"/settings")
view
|> element("a", "Delete")
|> element("#custom-fields-component a", "Delete")
|> render_click()
# Should show plural form
@ -78,10 +79,10 @@ defmodule MvWeb.CustomFieldLive.DeletionTest do
test "shows 0 members for custom field without values", %{conn: conn} do
{:ok, _custom_field} = create_custom_field("test_field", :string)
{:ok, view, _html} = live(conn, ~p"/custom_fields")
{:ok, view, _html} = live(conn, ~p"/settings")
view
|> element("a", "Delete")
|> element("#custom-fields-component a", "Delete")
|> render_click()
# Should show 0 members
@ -93,15 +94,16 @@ defmodule MvWeb.CustomFieldLive.DeletionTest do
test "updates confirmation state when typing", %{conn: conn} do
{:ok, custom_field} = create_custom_field("test_field", :string)
{:ok, view, _html} = live(conn, ~p"/custom_fields")
{:ok, view, _html} = live(conn, ~p"/settings")
view
|> element("a", "Delete")
|> element("#custom-fields-component a", "Delete")
|> render_click()
# Type in slug input
# Type in slug input - use element to find the form with phx-target
view
|> render_change("update_slug_confirmation", %{"slug" => custom_field.slug})
|> element("#delete-custom-field-modal form")
|> render_change(%{"slug" => custom_field.slug})
# Confirm button should be enabled now (no disabled attribute)
html = render(view)
@ -111,15 +113,16 @@ defmodule MvWeb.CustomFieldLive.DeletionTest do
test "delete button is disabled when slug doesn't match", %{conn: conn} do
{:ok, _custom_field} = create_custom_field("test_field", :string)
{:ok, view, _html} = live(conn, ~p"/custom_fields")
{:ok, view, _html} = live(conn, ~p"/settings")
view
|> element("a", "Delete")
|> element("#custom-fields-component a", "Delete")
|> render_click()
# Type wrong slug
# Type wrong slug - use element to find the form with phx-target
view
|> render_change("update_slug_confirmation", %{"slug" => "wrong-slug"})
|> element("#delete-custom-field-modal form")
|> render_change(%{"slug" => "wrong-slug"})
# Button should be disabled
html = render(view)
@ -133,20 +136,21 @@ defmodule MvWeb.CustomFieldLive.DeletionTest do
{:ok, custom_field} = create_custom_field("test_field", :string)
{:ok, custom_field_value} = create_custom_field_value(member, custom_field, "test")
{:ok, view, _html} = live(conn, ~p"/custom_fields")
{:ok, view, _html} = live(conn, ~p"/settings")
# Open modal
view
|> element("a", "Delete")
|> element("#custom-fields-component a", "Delete")
|> render_click()
# Enter correct slug
# Enter correct slug - use element to find the form with phx-target
view
|> render_change("update_slug_confirmation", %{"slug" => custom_field.slug})
|> element("#delete-custom-field-modal form")
|> render_change(%{"slug" => custom_field.slug})
# Click confirm
view
|> element("button", "Delete Custom Field and All Values")
|> element("#delete-custom-field-modal button", "Delete Custom Field and All Values")
|> render_click()
# Should show success message
@ -162,27 +166,28 @@ defmodule MvWeb.CustomFieldLive.DeletionTest do
assert {:ok, _} = Ash.get(Member, member.id)
end
test "shows error when slug doesn't match", %{conn: conn} do
test "button remains disabled and custom field not deleted when slug doesn't match", %{
conn: conn
} do
{:ok, custom_field} = create_custom_field("test_field", :string)
{:ok, view, _html} = live(conn, ~p"/custom_fields")
{:ok, view, _html} = live(conn, ~p"/settings")
view
|> element("a", "Delete")
|> element("#custom-fields-component a", "Delete")
|> render_click()
# Enter wrong slug
# Enter wrong slug - use element to find the form with phx-target
view
|> render_change("update_slug_confirmation", %{"slug" => "wrong-slug"})
|> element("#delete-custom-field-modal form")
|> render_change(%{"slug" => "wrong-slug"})
# Try to confirm (button should be disabled, but test the handler anyway)
view
|> render_click("confirm_delete", %{})
# Button should be disabled and we cannot click it
# The test verifies that the button is properly disabled in the UI
html = render(view)
assert html =~ ~r/disabled(?:=""|(?!\w))/
# Should show error message
assert render(view) =~ "Slug does not match"
# Custom field should still exist
# Custom field should still exist since deletion couldn't proceed
assert {:ok, _} = Ash.get(CustomField, custom_field.id)
end
end
@ -191,10 +196,10 @@ defmodule MvWeb.CustomFieldLive.DeletionTest do
test "closes modal without deleting", %{conn: conn} do
{:ok, custom_field} = create_custom_field("test_field", :string)
{:ok, view, _html} = live(conn, ~p"/custom_fields")
{:ok, view, _html} = live(conn, ~p"/settings")
view
|> element("a", "Delete")
|> element("#custom-fields-component a", "Delete")
|> render_click()
# Modal should be visible
@ -202,7 +207,7 @@ defmodule MvWeb.CustomFieldLive.DeletionTest do
# Click cancel
view
|> element("button", "Cancel")
|> element("#delete-custom-field-modal button", "Cancel")
|> render_click()
# Modal should be gone

View file

@ -0,0 +1,370 @@
defmodule MvWeb.MemberLive.Index.FieldSelectionTest do
@moduledoc """
Tests for FieldSelection module handling cookie/session/URL management.
"""
use ExUnit.Case, async: true
alias MvWeb.MemberLive.Index.FieldSelection
describe "get_from_session/1" do
test "returns empty map when session is empty" do
assert FieldSelection.get_from_session(%{}) == %{}
end
test "returns empty map when session key is missing" do
session = %{"other_key" => "value"}
assert FieldSelection.get_from_session(session) == %{}
end
test "parses valid JSON from session" do
json = Jason.encode!(%{"first_name" => true, "email" => false})
session = %{"member_field_selection" => json}
result = FieldSelection.get_from_session(session)
assert result == %{"first_name" => true, "email" => false}
end
test "handles invalid JSON gracefully" do
session = %{"member_field_selection" => "invalid json{["}
result = FieldSelection.get_from_session(session)
assert result == %{}
end
test "converts non-boolean values to true" do
json = Jason.encode!(%{"first_name" => "true", "email" => 1, "street" => true})
session = %{"member_field_selection" => json}
result = FieldSelection.get_from_session(session)
# All values should be booleans, non-booleans default to true
assert result["first_name"] == true
assert result["email"] == true
assert result["street"] == true
end
test "handles nil session" do
assert FieldSelection.get_from_session(nil) == %{}
end
test "handles non-map session" do
assert FieldSelection.get_from_session("not a map") == %{}
end
end
describe "save_to_session/2" do
test "saves field selection to session as JSON" do
session = %{}
selection = %{"first_name" => true, "email" => false}
result = FieldSelection.save_to_session(session, selection)
assert Map.has_key?(result, "member_field_selection")
assert Jason.decode!(result["member_field_selection"]) == selection
end
test "overwrites existing selection" do
session = %{"member_field_selection" => Jason.encode!(%{"old" => true})}
selection = %{"new" => true}
result = FieldSelection.save_to_session(session, selection)
assert Jason.decode!(result["member_field_selection"]) == selection
end
test "handles empty selection" do
session = %{}
selection = %{}
result = FieldSelection.save_to_session(session, selection)
assert Jason.decode!(result["member_field_selection"]) == %{}
end
test "handles invalid selection gracefully" do
session = %{}
result = FieldSelection.save_to_session(session, "not a map")
assert result == session
end
end
describe "get_from_cookie/1" do
test "returns empty map when cookie header is missing" do
conn = %Plug.Conn{}
result = FieldSelection.get_from_cookie(conn)
assert result == %{}
end
test "returns empty map when cookie is empty string" do
conn = Plug.Conn.put_req_header(%Plug.Conn{}, "cookie", "")
result = FieldSelection.get_from_cookie(conn)
assert result == %{}
end
test "parses valid JSON from cookie" do
selection = %{"first_name" => true, "email" => false}
cookie_value = selection |> Jason.encode!() |> URI.encode()
cookie_header = "member_field_selection=#{cookie_value}"
conn = %Plug.Conn{} |> Plug.Conn.put_req_header("cookie", cookie_header)
result = FieldSelection.get_from_cookie(conn)
assert result == selection
end
test "handles invalid JSON in cookie gracefully" do
cookie_value = URI.encode("invalid{[")
cookie_header = "member_field_selection=#{cookie_value}"
conn = %Plug.Conn{} |> Plug.Conn.put_req_header("cookie", cookie_header)
result = FieldSelection.get_from_cookie(conn)
assert result == %{}
end
test "handles cookie with other values" do
selection = %{"street" => true}
cookie_value = selection |> Jason.encode!() |> URI.encode()
cookie_header = "other_cookie=value; member_field_selection=#{cookie_value}; another=test"
conn = %Plug.Conn{} |> Plug.Conn.put_req_header("cookie", cookie_header)
result = FieldSelection.get_from_cookie(conn)
assert result == selection
end
end
describe "save_to_cookie/2" do
test "saves field selection to cookie" do
conn = %Plug.Conn{}
selection = %{"first_name" => true, "email" => false}
result = FieldSelection.save_to_cookie(conn, selection)
# Check that cookie is set
assert result.resp_cookies["member_field_selection"]
cookie = result.resp_cookies["member_field_selection"]
assert cookie[:max_age] == 365 * 24 * 60 * 60
assert cookie[:same_site] == "Lax"
assert cookie[:http_only] == true
end
test "handles invalid selection gracefully" do
conn = %Plug.Conn{}
result = FieldSelection.save_to_cookie(conn, "not a map")
assert result == conn
end
end
describe "parse_from_url/1" do
test "returns empty map when params is empty" do
assert FieldSelection.parse_from_url(%{}) == %{}
end
test "returns empty map when fields parameter is missing" do
params = %{"query" => "test", "sort_field" => "first_name"}
assert FieldSelection.parse_from_url(params) == %{}
end
test "parses comma-separated field names" do
params = %{"fields" => "first_name,email,street"}
result = FieldSelection.parse_from_url(params)
assert result == %{
"first_name" => true,
"email" => true,
"street" => true
}
end
test "handles custom field names" do
params = %{"fields" => "custom_field_abc-123,custom_field_def-456"}
result = FieldSelection.parse_from_url(params)
assert result == %{
"custom_field_abc-123" => true,
"custom_field_def-456" => true
}
end
test "handles mixed member and custom fields" do
params = %{"fields" => "first_name,custom_field_123,email"}
result = FieldSelection.parse_from_url(params)
assert result == %{
"first_name" => true,
"custom_field_123" => true,
"email" => true
}
end
test "trims whitespace from field names" do
params = %{"fields" => " first_name , email , street "}
result = FieldSelection.parse_from_url(params)
assert result == %{
"first_name" => true,
"email" => true,
"street" => true
}
end
test "handles empty fields string" do
params = %{"fields" => ""}
assert FieldSelection.parse_from_url(params) == %{}
end
test "handles nil fields parameter" do
params = %{"fields" => nil}
assert FieldSelection.parse_from_url(params) == %{}
end
test "filters out empty field names" do
params = %{"fields" => "first_name,,email,"}
result = FieldSelection.parse_from_url(params)
assert result == %{
"first_name" => true,
"email" => true
}
end
test "handles non-map params" do
assert FieldSelection.parse_from_url(nil) == %{}
assert FieldSelection.parse_from_url("not a map") == %{}
end
end
describe "merge_sources/3" do
test "merges all sources with URL having highest priority" do
url_selection = %{"first_name" => false}
session_selection = %{"first_name" => true, "email" => true}
cookie_selection = %{"first_name" => true, "street" => true}
result = FieldSelection.merge_sources(url_selection, session_selection, cookie_selection)
# URL overrides session, session overrides cookie
assert result["first_name"] == false
assert result["email"] == true
assert result["street"] == true
end
test "handles empty sources" do
result = FieldSelection.merge_sources(%{}, %{}, %{})
assert result == %{}
end
test "cookie only" do
cookie_selection = %{"first_name" => true}
result = FieldSelection.merge_sources(%{}, %{}, cookie_selection)
assert result == %{"first_name" => true}
end
test "session overrides cookie" do
session_selection = %{"first_name" => false}
cookie_selection = %{"first_name" => true}
result = FieldSelection.merge_sources(%{}, session_selection, cookie_selection)
assert result["first_name"] == false
end
test "URL overrides everything" do
url_selection = %{"first_name" => true}
session_selection = %{"first_name" => false}
cookie_selection = %{"first_name" => false}
result = FieldSelection.merge_sources(url_selection, session_selection, cookie_selection)
assert result["first_name"] == true
end
test "combines fields from all sources" do
url_selection = %{"url_field" => true}
session_selection = %{"session_field" => true}
cookie_selection = %{"cookie_field" => true}
result = FieldSelection.merge_sources(url_selection, session_selection, cookie_selection)
assert result["url_field"] == true
assert result["session_field"] == true
assert result["cookie_field"] == true
end
end
describe "to_url_param/1" do
test "converts selection to comma-separated string" do
selection = %{"first_name" => true, "email" => true, "street" => false}
result = FieldSelection.to_url_param(selection)
# Only visible fields should be included (order may vary)
fields = String.split(result, ",") |> Enum.sort()
assert fields == ["email", "first_name"]
end
test "handles empty selection" do
assert FieldSelection.to_url_param(%{}) == ""
end
test "handles all fields hidden" do
selection = %{"first_name" => false, "email" => false}
result = FieldSelection.to_url_param(selection)
assert result == ""
end
test "preserves field order" do
selection = %{
"z_field" => true,
"a_field" => true,
"m_field" => true
}
result = FieldSelection.to_url_param(selection)
# Order should be preserved (map iteration order)
assert String.contains?(result, "z_field")
assert String.contains?(result, "a_field")
assert String.contains?(result, "m_field")
end
test "handles custom fields" do
selection = %{
"first_name" => true,
"custom_field_abc-123" => true,
"email" => false
}
result = FieldSelection.to_url_param(selection)
assert String.contains?(result, "first_name")
assert String.contains?(result, "custom_field_abc-123")
refute String.contains?(result, "email")
end
test "handles invalid input" do
assert FieldSelection.to_url_param(nil) == ""
assert FieldSelection.to_url_param("not a map") == ""
end
end
end

View file

@ -0,0 +1,336 @@
defmodule MvWeb.MemberLive.Index.FieldVisibilityTest do
@moduledoc """
Tests for FieldVisibility module handling field visibility merging logic.
"""
use ExUnit.Case, async: true
alias MvWeb.MemberLive.Index.FieldVisibility
# Mock custom field structs for testing
defp create_custom_field(id, name, show_in_overview \\ true) do
%{
id: id,
name: name,
show_in_overview: show_in_overview
}
end
describe "get_all_available_fields/1" do
test "returns member fields and custom fields" do
custom_fields = [
create_custom_field("cf1", "Custom Field 1"),
create_custom_field("cf2", "Custom Field 2")
]
result = FieldVisibility.get_all_available_fields(custom_fields)
# Should include all member fields
assert :first_name in result
assert :email in result
assert :street in result
# Should include custom fields as strings
assert "custom_field_cf1" in result
assert "custom_field_cf2" in result
end
test "handles empty custom fields list" do
result = FieldVisibility.get_all_available_fields([])
# Should only have member fields
assert :first_name in result
assert :email in result
refute Enum.any?(result, fn field ->
is_binary(field) and String.starts_with?(field, "custom_field_")
end)
end
test "includes all member fields from constants" do
custom_fields = []
result = FieldVisibility.get_all_available_fields(custom_fields)
member_fields = Mv.Constants.member_fields()
Enum.each(member_fields, fn field ->
assert field in result
end)
end
end
describe "merge_with_global_settings/3" do
test "user selection overrides global settings" do
user_selection = %{"first_name" => false}
settings = %{member_field_visibility: %{first_name: true, email: true}}
custom_fields = []
result = FieldVisibility.merge_with_global_settings(user_selection, settings, custom_fields)
assert result["first_name"] == false
assert result["email"] == true
end
test "falls back to global settings when user selection is empty" do
user_selection = %{}
settings = %{member_field_visibility: %{first_name: false, email: true}}
custom_fields = []
result = FieldVisibility.merge_with_global_settings(user_selection, settings, custom_fields)
assert result["first_name"] == false
assert result["email"] == true
end
test "defaults to true when field not in settings" do
user_selection = %{}
settings = %{member_field_visibility: %{first_name: false}}
custom_fields = []
result = FieldVisibility.merge_with_global_settings(user_selection, settings, custom_fields)
# first_name from settings
assert result["first_name"] == false
# email defaults to true (not in settings)
assert result["email"] == true
end
test "handles custom fields visibility" do
user_selection = %{}
settings = %{member_field_visibility: %{}}
custom_fields = [
create_custom_field("cf1", "Custom 1", true),
create_custom_field("cf2", "Custom 2", false)
]
result = FieldVisibility.merge_with_global_settings(user_selection, settings, custom_fields)
assert result["custom_field_cf1"] == true
assert result["custom_field_cf2"] == false
end
test "user selection overrides custom field visibility" do
user_selection = %{"custom_field_cf1" => false}
settings = %{member_field_visibility: %{}}
custom_fields = [
create_custom_field("cf1", "Custom 1", true)
]
result = FieldVisibility.merge_with_global_settings(user_selection, settings, custom_fields)
assert result["custom_field_cf1"] == false
end
test "handles string keys in settings (JSONB format)" do
user_selection = %{}
settings = %{member_field_visibility: %{"first_name" => false, "email" => true}}
custom_fields = []
result = FieldVisibility.merge_with_global_settings(user_selection, settings, custom_fields)
assert result["first_name"] == false
assert result["email"] == true
end
test "handles mixed atom and string keys in settings" do
user_selection = %{}
# Use string keys only (as JSONB would return)
settings = %{member_field_visibility: %{"first_name" => false, "email" => true}}
custom_fields = []
result = FieldVisibility.merge_with_global_settings(user_selection, settings, custom_fields)
assert result["first_name"] == false
assert result["email"] == true
end
test "handles nil settings gracefully" do
user_selection = %{}
settings = %{member_field_visibility: nil}
custom_fields = []
result = FieldVisibility.merge_with_global_settings(user_selection, settings, custom_fields)
# Should default all fields to true
assert result["first_name"] == true
assert result["email"] == true
end
test "handles missing member_field_visibility key" do
user_selection = %{}
settings = %{}
custom_fields = []
result = FieldVisibility.merge_with_global_settings(user_selection, settings, custom_fields)
# Should default all fields to true
assert result["first_name"] == true
assert result["email"] == true
end
test "includes all fields in result" do
user_selection = %{"first_name" => false}
settings = %{member_field_visibility: %{email: true}}
custom_fields = [
create_custom_field("cf1", "Custom 1", true)
]
result = FieldVisibility.merge_with_global_settings(user_selection, settings, custom_fields)
# Should include all member fields
member_fields = Mv.Constants.member_fields()
Enum.each(member_fields, fn field ->
assert Map.has_key?(result, Atom.to_string(field))
end)
# Should include custom fields
assert Map.has_key?(result, "custom_field_cf1")
end
end
describe "get_visible_fields/1" do
test "returns only fields with true visibility" do
selection = %{
"first_name" => true,
"email" => false,
"street" => true,
"custom_field_123" => false
}
result = FieldVisibility.get_visible_fields(selection)
assert :first_name in result
assert :street in result
refute :email in result
refute "custom_field_123" in result
end
test "converts member field strings to atoms" do
selection = %{"first_name" => true, "email" => true}
result = FieldVisibility.get_visible_fields(selection)
assert :first_name in result
assert :email in result
end
test "keeps custom fields as strings" do
selection = %{"custom_field_abc-123" => true}
result = FieldVisibility.get_visible_fields(selection)
assert "custom_field_abc-123" in result
end
test "handles empty selection" do
assert FieldVisibility.get_visible_fields(%{}) == []
end
test "handles all fields hidden" do
selection = %{"first_name" => false, "email" => false}
assert FieldVisibility.get_visible_fields(selection) == []
end
test "handles invalid input" do
assert FieldVisibility.get_visible_fields(nil) == []
end
end
describe "get_visible_member_fields/1" do
test "returns only member fields that are visible" do
selection = %{
"first_name" => true,
"email" => true,
"custom_field_123" => true,
"street" => false
}
result = FieldVisibility.get_visible_member_fields(selection)
assert :first_name in result
assert :email in result
refute :street in result
refute "custom_field_123" in result
end
test "filters out custom fields" do
selection = %{
"first_name" => true,
"custom_field_123" => true,
"custom_field_456" => true
}
result = FieldVisibility.get_visible_member_fields(selection)
assert :first_name in result
refute "custom_field_123" in result
refute "custom_field_456" in result
end
test "handles empty selection" do
assert FieldVisibility.get_visible_member_fields(%{}) == []
end
test "handles invalid input" do
assert FieldVisibility.get_visible_member_fields(nil) == []
end
end
describe "get_visible_custom_fields/1" do
test "returns only custom fields that are visible" do
selection = %{
"first_name" => true,
"custom_field_123" => true,
"custom_field_456" => false,
"email" => true
}
result = FieldVisibility.get_visible_custom_fields(selection)
assert "custom_field_123" in result
refute "custom_field_456" in result
refute :first_name in result
refute :email in result
end
test "filters out member fields" do
selection = %{
"first_name" => true,
"email" => true,
"custom_field_123" => true
}
result = FieldVisibility.get_visible_custom_fields(selection)
assert "custom_field_123" in result
refute :first_name in result
refute :email in result
end
test "handles empty selection" do
assert FieldVisibility.get_visible_custom_fields(%{}) == []
end
test "handles fields that look like custom fields but aren't" do
selection = %{
"custom_field_123" => true,
"custom_field_like_name" => true,
"not_custom_field" => true
}
result = FieldVisibility.get_visible_custom_fields(selection)
assert "custom_field_123" in result
assert "custom_field_like_name" in result
refute "not_custom_field" in result
end
test "handles invalid input" do
assert FieldVisibility.get_visible_custom_fields(nil) == []
end
end
end

View file

@ -90,8 +90,6 @@ defmodule MvWeb.ProfileNavigationTest do
# Verify we're on the correct profile page with OIDC specific information
{:ok, _profile_view, html} = live(conn, "/users/#{user.id}")
assert html =~ to_string(user.email)
# OIDC ID should be visible
assert html =~ "oidc_123"
# Password auth should be disabled for OIDC users
assert html =~ "Not enabled"
end
@ -150,8 +148,6 @@ defmodule MvWeb.ProfileNavigationTest do
"/members/new",
"/custom_field_values",
"/custom_field_values/new",
"/custom_fields",
"/custom_fields/new",
"/users",
"/users/new"
]

View file

@ -231,8 +231,8 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsDisplayTest do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members")
# Date should be displayed in readable format
assert html =~ "1990" or html =~ "1990-05-15" or html =~ "15.05.1990"
# Date should be displayed in European format (dd.mm.yyyy)
assert html =~ "15.05.1990"
end
test "formats email custom field values correctly", %{conn: conn, member1: _member1} do
@ -242,7 +242,7 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsDisplayTest do
assert html =~ "alice.private@example.com"
end
test "shows empty cell or placeholder for members without custom field values", %{
test "shows empty cell for members without custom field values", %{
conn: conn,
member2: _member2,
field_show_string: field
@ -253,11 +253,14 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsDisplayTest do
# The custom field column should exist
assert html =~ field.name
# Member2 should have an empty cell for this field
# We check that member2's row exists but doesn't have the value
assert html =~ "Bob Brown"
# The value should not appear for member2 (only for member1)
# We check that the value appears somewhere (for member1) but member2 row should have "-"
# Member2 should exist in the table (first_name and last_name are in separate columns)
assert html =~ "Bob"
assert html =~ "Brown"
# The value from member1 should appear (phone number)
assert html =~ "+49123456789"
# Note: Member2 doesn't have this custom field value, so the cell is empty
# The implementation shows "" for missing values, which is the expected behavior
end
end

View file

@ -0,0 +1,452 @@
defmodule MvWeb.MemberLive.IndexFieldVisibilityTest do
@moduledoc """
Integration tests for field visibility dropdown functionality.
Tests cover:
- Field selection dropdown rendering
- Toggling field visibility
- URL parameter persistence
- Select all / deselect all
- Integration with member list display
- Custom fields visibility
"""
use MvWeb.ConnCase, async: true
import Phoenix.LiveViewTest
require Ash.Query
alias Mv.Membership.{CustomField, CustomFieldValue, Member}
setup do
# Create test members
{:ok, member1} =
Member
|> Ash.Changeset.for_create(:create_member, %{
first_name: "Alice",
last_name: "Anderson",
email: "alice@example.com",
street: "Main St",
city: "Berlin"
})
|> Ash.create()
{:ok, member2} =
Member
|> Ash.Changeset.for_create(:create_member, %{
first_name: "Bob",
last_name: "Brown",
email: "bob@example.com",
street: "Second St",
city: "Hamburg"
})
|> Ash.create()
# Create custom field
{:ok, custom_field} =
CustomField
|> Ash.Changeset.for_create(:create, %{
name: "membership_number",
value_type: :string,
show_in_overview: true
})
|> Ash.create()
# Create custom field values
{:ok, _cfv1} =
CustomFieldValue
|> Ash.Changeset.for_create(:create, %{
member_id: member1.id,
custom_field_id: custom_field.id,
value: "M001"
})
|> Ash.create()
{:ok, _cfv2} =
CustomFieldValue
|> Ash.Changeset.for_create(:create, %{
member_id: member2.id,
custom_field_id: custom_field.id,
value: "M002"
})
|> Ash.create()
%{
member1: member1,
member2: member2,
custom_field: custom_field
}
end
describe "field visibility dropdown" do
test "renders dropdown button", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members")
assert html =~ "Columns"
assert html =~ ~s(aria-controls="field-visibility-menu")
end
test "opens dropdown when button is clicked", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Initially closed
refute has_element?(view, "ul#field-visibility-menu")
# Click button
view
|> element("button[aria-controls='field-visibility-menu']")
|> render_click()
# Should be open now
assert has_element?(view, "ul#field-visibility-menu")
end
test "displays all member fields in dropdown", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Open dropdown
view
|> element("button[aria-controls='field-visibility-menu']")
|> render_click()
html = render(view)
# Check for member fields (formatted labels)
assert html =~ "First Name" or html =~ "first_name"
assert html =~ "Email" or html =~ "email"
assert html =~ "Street" or html =~ "street"
end
test "displays custom fields in dropdown", %{conn: conn, custom_field: custom_field} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Open dropdown
view
|> element("button[aria-controls='field-visibility-menu']")
|> render_click()
html = render(view)
assert html =~ custom_field.name
end
end
describe "field visibility toggling" do
test "hiding a field removes it from display", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Verify email is visible initially
html = render(view)
assert html =~ "alice@example.com"
# Open dropdown and hide email
view
|> element("button[aria-controls='field-visibility-menu']")
|> render_click()
view
|> element("button[phx-click='select_item'][phx-value-item='email']")
|> render_click()
# Wait for update
:timer.sleep(100)
# Email should no longer be visible
html = render(view)
refute html =~ "alice@example.com"
refute html =~ "bob@example.com"
end
test "hiding custom field removes it from display", %{conn: conn, custom_field: custom_field} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Verify custom field is visible initially
html = render(view)
assert html =~ "M001" or html =~ custom_field.name
# Open dropdown and hide custom field
view
|> element("button[aria-controls='field-visibility-menu']")
|> render_click()
custom_field_id = custom_field.id
custom_field_string = "custom_field_#{custom_field_id}"
view
|> element("button[phx-click='select_item'][phx-value-item='#{custom_field_string}']")
|> render_click()
# Wait for update
:timer.sleep(100)
# Custom field should no longer be visible
html = render(view)
refute html =~ "M001"
refute html =~ "M002"
end
end
describe "select all / deselect all" do
test "select all makes all fields visible", %{conn: conn} do
conn = conn_with_oidc_user(conn)
# Start with some fields hidden
{:ok, view, _html} = live(conn, "/members?fields=first_name")
# Open dropdown
view
|> element("button[aria-controls='field-visibility-menu']")
|> render_click()
# Click select all
view
|> element("button[phx-click='select_all']")
|> render_click()
# Wait for update
:timer.sleep(100)
# All fields should be visible
html = render(view)
assert html =~ "alice@example.com"
assert html =~ "Main St"
assert html =~ "Berlin"
end
test "deselect all hides all fields except first_name", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Open dropdown
view
|> element("button[aria-controls='field-visibility-menu']")
|> render_click()
# Click deselect all
view
|> element("button[phx-click='select_none']")
|> render_click()
# Wait for update
:timer.sleep(100)
# Only first_name should be visible (it's always shown)
html = render(view)
# Email and street should be hidden
refute html =~ "alice@example.com"
refute html =~ "Main St"
end
end
describe "URL parameter persistence" do
test "field selection is persisted in URL", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Open dropdown and hide email
view
|> element("button[aria-controls='field-visibility-menu']")
|> render_click()
view
|> element("button[phx-click='select_item'][phx-value-item='email']")
|> render_click()
# Wait for URL update
:timer.sleep(100)
# Check that URL contains fields parameter
# Note: In LiveView tests, we check the rendered HTML for the updated state
# The actual URL update happens via push_patch
end
test "loading page with fields parameter applies selection", %{conn: conn} do
conn = conn_with_oidc_user(conn)
# Load with first_name and city explicitly set in URL
# Note: Other fields may still be visible due to global settings
{:ok, view, _html} = live(conn, "/members?fields=first_name,city")
html = render(view)
# first_name and city should be visible
assert html =~ "Alice"
assert html =~ "Berlin"
# Note: email and street may still be visible if global settings allow it
# This test verifies that the URL parameters work, not that they hide other fields
end
test "fields parameter works with custom fields", %{conn: conn, custom_field: custom_field} do
conn = conn_with_oidc_user(conn)
custom_field_id = custom_field.id
# Load with custom field visible
{:ok, view, _html} =
live(conn, "/members?fields=first_name,custom_field_#{custom_field_id}")
html = render(view)
# Custom field should be visible
assert html =~ "M001" or html =~ custom_field.name
end
end
describe "integration with global settings" do
test "respects global settings when no user selection", %{conn: conn} do
# This test would require setting up global settings
# For now, we verify that the system works with default settings
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members")
# All fields should be visible by default
assert html =~ "alice@example.com"
assert html =~ "Main St"
end
test "user selection overrides global settings", %{conn: conn} do
# This would require setting up global settings first
# Then verifying that user selection takes precedence
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Hide a field via dropdown
view
|> element("button[aria-controls='field-visibility-menu']")
|> render_click()
view
|> element("button[phx-click='select_item'][phx-value-item='email']")
|> render_click()
:timer.sleep(100)
html = render(view)
refute html =~ "alice@example.com"
end
end
describe "edge cases" do
test "handles empty fields parameter", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members?fields=")
# Should fall back to global settings
assert html =~ "alice@example.com"
end
test "handles invalid field names in URL", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members?fields=invalid_field,another_invalid")
# Should ignore invalid fields and use defaults
assert html =~ "alice@example.com"
end
test "handles custom field that doesn't exist", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members?fields=first_name,custom_field_nonexistent")
# Should work without errors
assert html =~ "Alice"
end
test "handles rapid toggling", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Open dropdown
view
|> element("button[aria-controls='field-visibility-menu']")
|> render_click()
# Rapidly toggle a field multiple times
for _ <- 1..5 do
view
|> element("button[phx-click='select_item'][phx-value-item='email']")
|> render_click()
:timer.sleep(50)
end
# Should still work correctly
html = render(view)
assert html =~ "Alice"
end
end
describe "accessibility" do
test "dropdown has proper ARIA attributes", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members")
assert html =~ ~s(aria-controls="field-visibility-menu")
assert html =~ ~s(aria-haspopup="menu")
assert html =~ ~s(role="button")
end
test "menu items have proper ARIA attributes when open", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Open dropdown
view
|> element("button[aria-controls='field-visibility-menu']")
|> render_click()
html = render(view)
assert html =~ ~s(role="menu")
assert html =~ ~s(role="menuitemcheckbox")
assert html =~ ~s(aria-checked)
end
test "keyboard navigation works", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Open dropdown
view
|> element("button[aria-controls='field-visibility-menu']")
|> render_click()
# Check that elements are keyboard accessible
html = render(view)
assert html =~ ~s(tabindex="0")
# Check that keyboard events are supported
assert html =~ ~s(phx-keydown="select_item")
assert html =~ ~s(phx-key="Enter")
end
test "keyboard activation with Enter key works", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Verify email is visible initially
html = render(view)
assert html =~ "alice@example.com"
# Open dropdown
view
|> element("button[aria-controls='field-visibility-menu']")
|> render_click()
# Simulate Enter key press on email field button
view
|> element("button[phx-click='select_item'][phx-value-item='email']")
|> render_keydown(%{key: "Enter"})
# Wait for update
:timer.sleep(100)
# Email should no longer be visible
html = render(view)
refute html =~ "alice@example.com"
end
end
end

View file

@ -33,8 +33,6 @@ defmodule MvWeb.UserLive.IndexTest do
assert html =~ "alice@example.com"
assert html =~ "bob@example.com"
assert html =~ "alice123"
assert html =~ "bob456"
end
test "shows correct action links", %{conn: conn} do
@ -386,10 +384,6 @@ defmodule MvWeb.UserLive.IndexTest do
# Should still show the table structure
assert html =~ "Email"
assert html =~ "OIDC ID"
# Should show the authenticated user at minimum
# Matches the generated email pattern oidc.user{unique_id}@example.com
assert html =~ "oidc.user"
end
test "handles users with missing OIDC ID", %{conn: conn} do

View file

@ -123,7 +123,13 @@ defmodule MvWeb.ConnCase do
end
setup tags do
Mv.DataCase.setup_sandbox(tags)
{:ok, conn: Phoenix.ConnTest.build_conn()}
pid = Mv.DataCase.setup_sandbox(tags)
conn = Phoenix.ConnTest.build_conn()
# Set metadata for Phoenix.Ecto.SQL.Sandbox plug to allow LiveView processes
# to share the test's database connection in async tests
conn = Plug.Conn.put_private(conn, :ecto_sandbox, pid)
{:ok, conn: conn}
end
end

View file

@ -34,10 +34,12 @@ defmodule Mv.DataCase do
@doc """
Sets up the sandbox based on the test tags.
Returns the owner pid for use with Phoenix.Ecto.SQL.Sandbox.
"""
def setup_sandbox(tags) do
pid = Ecto.Adapters.SQL.Sandbox.start_owner!(Mv.Repo, shared: not tags[:async])
on_exit(fn -> Ecto.Adapters.SQL.Sandbox.stop_owner(pid) end)
pid
end
@doc """