From 173f522da5eb9587efa7b4db85d53d37f03fc8ba Mon Sep 17 00:00:00 2001 From: Moritz Date: Thu, 20 Nov 2025 15:51:44 +0100 Subject: [PATCH 01/26] test: add tests for user-member linking and fuzzy search (#168) --- .../user_member_linking_email_test.exs | 169 +++++++++++++ test/accounts/user_member_linking_test.exs | 130 ++++++++++ .../member_available_for_linking_test.exs | 222 ++++++++++++++++++ .../member_fuzzy_search_linking_test.exs | 158 +++++++++++++ 4 files changed, 679 insertions(+) create mode 100644 test/accounts/user_member_linking_email_test.exs create mode 100644 test/accounts/user_member_linking_test.exs create mode 100644 test/membership/member_available_for_linking_test.exs create mode 100644 test/membership/member_fuzzy_search_linking_test.exs diff --git a/test/accounts/user_member_linking_email_test.exs b/test/accounts/user_member_linking_email_test.exs new file mode 100644 index 0000000..d7c2817 --- /dev/null +++ b/test/accounts/user_member_linking_email_test.exs @@ -0,0 +1,169 @@ +defmodule Mv.Accounts.UserMemberLinkingEmailTest do + @moduledoc """ + Tests email validation during user-member linking. + Implements rules from docs/email-sync.md. + Tests for Issue #168, specifically Problem #4: Email validation bug. + """ + + use Mv.DataCase, async: false + + alias Mv.Accounts + alias Mv.Membership + + describe "link with same email" do + test "succeeds when user.email == member.email" do + # Create member with specific email + {:ok, member} = + Membership.create_member(%{ + first_name: "Alice", + last_name: "Johnson", + email: "alice@example.com" + }) + + # Create user with same email and link to member + result = + Accounts.create_user(%{ + email: "alice@example.com", + member: %{id: member.id} + }) + + # Should succeed without errors + assert {:ok, user} = result + assert to_string(user.email) == "alice@example.com" + + # Reload to verify link + user = Ash.load!(user, [:member], domain: Mv.Accounts) + assert user.member.id == member.id + assert user.member.email == "alice@example.com" + end + + test "no validation error triggered when updating linked pair with same email" do + # Create member + {:ok, member} = + Membership.create_member(%{ + first_name: "Bob", + last_name: "Smith", + email: "bob@example.com" + }) + + # Create user and link + {:ok, user} = + Accounts.create_user(%{ + email: "bob@example.com", + member: %{id: member.id} + }) + + # Update user (should not trigger email validation error) + result = Accounts.update_user(user, %{email: "bob@example.com"}) + + assert {:ok, updated_user} = result + assert to_string(updated_user.email) == "bob@example.com" + end + end + + describe "link with different emails" do + test "fails if member.email is used by a DIFFERENT linked user" do + # Create first user and link to a different member + {:ok, other_member} = + Membership.create_member(%{ + first_name: "Other", + last_name: "Member", + email: "other@example.com" + }) + + {:ok, _user1} = + Accounts.create_user(%{ + email: "user1@example.com", + member: %{id: other_member.id} + }) + + # Reload to ensure email sync happened + _other_member = Ash.reload!(other_member) + + # Create a NEW member with different email + {:ok, member} = + Membership.create_member(%{ + first_name: "Charlie", + last_name: "Brown", + email: "charlie@example.com" + }) + + # Try to create user2 with email that matches the linked other_member + result = + Accounts.create_user(%{ + email: "user1@example.com", + member: %{id: member.id} + }) + + # Should fail because user1@example.com is already used by other_member (which is linked to user1) + assert {:error, _error} = result + end + + test "succeeds for unique emails" do + # Create member + {:ok, member} = + Membership.create_member(%{ + first_name: "David", + last_name: "Wilson", + email: "david@example.com" + }) + + # Create user with different but unique email + result = + Accounts.create_user(%{ + email: "user@example.com", + member: %{id: member.id} + }) + + # Should succeed + assert {:ok, user} = result + + # Email sync should update member's email to match user's + user = Ash.load!(user, [:member], domain: Mv.Accounts) + assert user.member.email == "user@example.com" + end + end + + describe "edge cases" do + test "unlinking and relinking with same email works (Problem #4)" do + # This is the exact scenario from Problem #4: + # 1. Link user and member (both have same email) + # 2. Unlink them (member keeps the email) + # 3. Try to relink (validation should NOT fail) + + # Create member + {:ok, member} = + Membership.create_member(%{ + first_name: "Emma", + last_name: "Davis", + email: "emma@example.com" + }) + + # Create user and link + {:ok, user} = + Accounts.create_user(%{ + email: "emma@example.com", + member: %{id: member.id} + }) + + # Verify they are linked + user = Ash.load!(user, [:member], domain: Mv.Accounts) + assert user.member.id == member.id + assert user.member.email == "emma@example.com" + + # Unlink + {:ok, unlinked_user} = Accounts.update_user(user, %{member: nil}) + assert is_nil(unlinked_user.member_id) + + # Member still has the email after unlink + member = Ash.reload!(member) + assert member.email == "emma@example.com" + + # Relink (should work - this is Problem #4) + result = Accounts.update_user(unlinked_user, %{member: %{id: member.id}}) + + assert {:ok, relinked_user} = result + assert relinked_user.member_id == member.id + end + end +end diff --git a/test/accounts/user_member_linking_test.exs b/test/accounts/user_member_linking_test.exs new file mode 100644 index 0000000..1111436 --- /dev/null +++ b/test/accounts/user_member_linking_test.exs @@ -0,0 +1,130 @@ +defmodule Mv.Accounts.UserMemberLinkingTest do + @moduledoc """ + Integration tests for User-Member linking functionality. + + Tests the complete workflow of linking and unlinking members to users, + including email synchronization and validation rules. + """ + use Mv.DataCase, async: false + alias Mv.Accounts + alias Mv.Membership + + describe "User-Member Linking with Email Sync" do + test "link user to member with different email syncs member email" do + # Create user with one email + {:ok, user} = Accounts.create_user(%{email: "user@example.com"}) + + # Create member with different email + {:ok, member} = + Membership.create_member(%{ + first_name: "John", + last_name: "Doe", + email: "member@example.com" + }) + + # Link user to member + {:ok, updated_user} = Accounts.update_user(user, %{member: %{id: member.id}}) + + # Verify link exists + user_with_member = Ash.get!(Mv.Accounts.User, updated_user.id, load: [:member]) + assert user_with_member.member.id == member.id + + # Verify member email was synced to match user email + synced_member = Ash.get!(Mv.Membership.Member, member.id) + assert synced_member.email == "user@example.com" + end + + test "unlink member from user sets member to nil" do + # Create and link user and member + {:ok, user} = Accounts.create_user(%{email: "user@example.com"}) + + {:ok, member} = + Membership.create_member(%{ + first_name: "Jane", + last_name: "Smith", + email: "jane@example.com" + }) + + {:ok, linked_user} = Accounts.update_user(user, %{member: %{id: member.id}}) + + # Verify link exists + user_with_member = Ash.get!(Mv.Accounts.User, linked_user.id, load: [:member]) + assert user_with_member.member.id == member.id + + # Unlink by setting member to nil + {:ok, unlinked_user} = Accounts.update_user(linked_user, %{member: nil}) + + # Verify link is removed + user_without_member = Ash.get!(Mv.Accounts.User, unlinked_user.id, load: [:member]) + assert is_nil(user_without_member.member) + + # Verify member still exists independently + member_still_exists = Ash.get!(Mv.Membership.Member, member.id) + assert member_still_exists.id == member.id + end + + test "cannot link member already linked to another user" do + # Create first user and link to member + {:ok, user1} = Accounts.create_user(%{email: "user1@example.com"}) + + {:ok, member} = + Membership.create_member(%{ + first_name: "Bob", + last_name: "Wilson", + email: "bob@example.com" + }) + + {:ok, _linked_user1} = Accounts.update_user(user1, %{member: %{id: member.id}}) + + # Create second user and try to link to same member + {:ok, user2} = Accounts.create_user(%{email: "user2@example.com"}) + + # Should fail because member is already linked + assert {:error, %Ash.Error.Invalid{}} = + Accounts.update_user(user2, %{member: %{id: member.id}}) + end + + test "cannot change member link directly, must unlink first" do + # Create user and link to first member + {:ok, user} = Accounts.create_user(%{email: "user@example.com"}) + + {:ok, member1} = + Membership.create_member(%{ + first_name: "Alice", + last_name: "Johnson", + email: "alice@example.com" + }) + + {:ok, linked_user} = Accounts.update_user(user, %{member: %{id: member1.id}}) + + # Create second member + {:ok, member2} = + Membership.create_member(%{ + first_name: "Charlie", + last_name: "Brown", + email: "charlie@example.com" + }) + + # Try to directly change member link (should fail) + assert {:error, %Ash.Error.Invalid{errors: errors}} = + Accounts.update_user(linked_user, %{member: %{id: member2.id}}) + + # Verify error message mentions "Remove existing member first" + error_messages = Enum.map(errors, & &1.message) + assert Enum.any?(error_messages, &String.contains?(&1, "Remove existing member first")) + + # Two-step process: first unlink, then link new member + {:ok, unlinked_user} = Accounts.update_user(linked_user, %{member: nil}) + + # After unlinking, member1 still has the user's email + # Change member1's email to avoid conflict when relinking to member2 + {:ok, _} = Membership.update_member(member1, %{email: "alice_changed@example.com"}) + + {:ok, relinked_user} = Accounts.update_user(unlinked_user, %{member: %{id: member2.id}}) + + # Verify new link is established + user_with_new_member = Ash.get!(Mv.Accounts.User, relinked_user.id, load: [:member]) + assert user_with_new_member.member.id == member2.id + end + end +end diff --git a/test/membership/member_available_for_linking_test.exs b/test/membership/member_available_for_linking_test.exs new file mode 100644 index 0000000..af293e1 --- /dev/null +++ b/test/membership/member_available_for_linking_test.exs @@ -0,0 +1,222 @@ +defmodule Mv.Membership.MemberAvailableForLinkingTest do + @moduledoc """ + Tests for the Member.available_for_linking action. + + This action returns members that can be linked to a user account: + - Only members without existing user links (user_id == nil) + - Limited to 10 results + - Special email-match logic: if user_email matches member email, only return that member + - Optional search query filtering by name and email + """ + use Mv.DataCase, async: false + alias Mv.Membership + + describe "available_for_linking/2" do + setup do + # Create 5 unlinked members with distinct names + {:ok, member1} = + Membership.create_member(%{ + first_name: "Alice", + last_name: "Anderson", + email: "alice@example.com" + }) + + {:ok, member2} = + Membership.create_member(%{ + first_name: "Bob", + last_name: "Williams", + email: "bob@example.com" + }) + + {:ok, member3} = + Membership.create_member(%{ + first_name: "Charlie", + last_name: "Davis", + email: "charlie@example.com" + }) + + {:ok, member4} = + Membership.create_member(%{ + first_name: "Diana", + last_name: "Martinez", + email: "diana@example.com" + }) + + {:ok, member5} = + Membership.create_member(%{ + first_name: "Emma", + last_name: "Taylor", + email: "emma@example.com" + }) + + unlinked_members = [member1, member2, member3, member4, member5] + + # Create 2 linked members (with users) + {:ok, user1} = Mv.Accounts.create_user(%{email: "user1@example.com"}) + + {:ok, linked_member1} = + Membership.create_member(%{ + first_name: "Linked", + last_name: "Member1", + email: "linked1@example.com", + user: %{id: user1.id} + }) + + {:ok, user2} = Mv.Accounts.create_user(%{email: "user2@example.com"}) + + {:ok, linked_member2} = + Membership.create_member(%{ + first_name: "Linked", + last_name: "Member2", + email: "linked2@example.com", + user: %{id: user2.id} + }) + + %{ + unlinked_members: unlinked_members, + linked_members: [linked_member1, linked_member2] + } + end + + test "returns only unlinked members and limits to 10", %{ + unlinked_members: unlinked_members, + linked_members: _linked_members + } do + # Call the action without any arguments + members = + Mv.Membership.Member + |> Ash.Query.for_read(:available_for_linking, %{}) + |> Ash.read!() + + # Should return only the 5 unlinked members, not the 2 linked ones + assert length(members) == 5 + + returned_ids = Enum.map(members, & &1.id) |> MapSet.new() + expected_ids = Enum.map(unlinked_members, & &1.id) |> MapSet.new() + + assert MapSet.equal?(returned_ids, expected_ids) + + # Verify none of the returned members have a user_id + Enum.each(members, fn member -> + member_with_user = Ash.get!(Mv.Membership.Member, member.id, load: [:user]) + assert is_nil(member_with_user.user) + end) + end + + test "limits results to 10 members even when more exist" do + # Create 15 additional unlinked members (total 20 unlinked) + for i <- 6..20 do + Membership.create_member(%{ + first_name: "Extra#{i}", + last_name: "Member#{i}", + email: "extra#{i}@example.com" + }) + end + + members = + Mv.Membership.Member + |> Ash.Query.for_read(:available_for_linking, %{}) + |> Ash.read!() + + # Should be limited to 10 + assert length(members) == 10 + end + + test "email match: returns only member with matching email when exists", %{ + unlinked_members: unlinked_members + } do + # Get one of the unlinked members' email + target_member = List.first(unlinked_members) + user_email = target_member.email + + raw_members = + Mv.Membership.Member + |> Ash.Query.for_read(:available_for_linking, %{user_email: user_email}) + |> Ash.read!() + + # Apply email match filtering (sorted results come from query) + # When user_email matches, only that member should be returned + members = Mv.Membership.Member.filter_by_email_match(raw_members, user_email) + + # Should return only the member with matching email + assert length(members) == 1 + assert List.first(members).id == target_member.id + assert List.first(members).email == user_email + end + + test "email match: returns all unlinked members when no email match" do + # Use an email that doesn't match any member + non_matching_email = "nonexistent@example.com" + + raw_members = + Mv.Membership.Member + |> Ash.Query.for_read(:available_for_linking, %{user_email: non_matching_email}) + |> Ash.read!() + + # Apply email match filtering + members = Mv.Membership.Member.filter_by_email_match(raw_members, non_matching_email) + + # Should return all 5 unlinked members since no match + assert length(members) == 5 + end + + test "search query: filters by first_name, last_name, and email", %{ + unlinked_members: _unlinked_members + } do + # Search by first name + members = + Mv.Membership.Member + |> Ash.Query.for_read(:available_for_linking, %{search_query: "Alice"}) + |> Ash.read!() + + assert length(members) == 1 + assert List.first(members).first_name == "Alice" + + # Search by last name + members = + Mv.Membership.Member + |> Ash.Query.for_read(:available_for_linking, %{search_query: "Williams"}) + |> Ash.read!() + + assert length(members) == 1 + assert List.first(members).last_name == "Williams" + + # Search by email + members = + Mv.Membership.Member + |> Ash.Query.for_read(:available_for_linking, %{search_query: "charlie@"}) + |> Ash.read!() + + assert length(members) == 1 + assert List.first(members).email == "charlie@example.com" + + # Search returns empty when no matches + members = + Mv.Membership.Member + |> Ash.Query.for_read(:available_for_linking, %{search_query: "NonExistent"}) + |> Ash.read!() + + assert Enum.empty?(members) + end + + test "search query takes precedence over email match", %{unlinked_members: unlinked_members} do + target_member = List.first(unlinked_members) + + # Pass both email match and search query that would match different members + raw_members = + Mv.Membership.Member + |> Ash.Query.for_read(:available_for_linking, %{ + user_email: target_member.email, + search_query: "Bob" + }) + |> Ash.read!() + + # Search query takes precedence, should match "Bob" in the first name + # user_email is used for POST-filtering only, not in the query + assert length(raw_members) == 1 + # Should find the member with "Bob" first name, not target_member (Alice) + assert List.first(raw_members).first_name == "Bob" + refute List.first(raw_members).id == target_member.id + end + end +end diff --git a/test/membership/member_fuzzy_search_linking_test.exs b/test/membership/member_fuzzy_search_linking_test.exs new file mode 100644 index 0000000..4cbd8d9 --- /dev/null +++ b/test/membership/member_fuzzy_search_linking_test.exs @@ -0,0 +1,158 @@ +defmodule Mv.Membership.MemberFuzzySearchLinkingTest do + @moduledoc """ + Tests fuzzy search in Member.available_for_linking action. + Verifies PostgreSQL trigram matching for member search. + """ + + use Mv.DataCase, async: false + + alias Mv.Accounts + alias Mv.Membership + + describe "available_for_linking with fuzzy search" do + test "finds member despite typo" do + # Create member with specific name + {:ok, member} = + Membership.create_member(%{ + first_name: "Jonathan", + last_name: "Smith", + email: "jonathan@example.com" + }) + + # Search with typo + query = + Mv.Membership.Member + |> Ash.Query.for_read(:available_for_linking, %{ + user_email: nil, + search_query: "Jonatan" + }) + + {:ok, members} = Ash.read(query, domain: Mv.Membership) + + # Should find Jonathan despite typo + assert length(members) == 1 + assert hd(members).id == member.id + end + + test "finds member with partial match" do + # Create member + {:ok, member} = + Membership.create_member(%{ + first_name: "Alexander", + last_name: "Williams", + email: "alex@example.com" + }) + + # Search with partial + query = + Mv.Membership.Member + |> Ash.Query.for_read(:available_for_linking, %{ + user_email: nil, + search_query: "Alex" + }) + + {:ok, members} = Ash.read(query, domain: Mv.Membership) + + # Should find Alexander + assert length(members) == 1 + assert hd(members).id == member.id + end + + test "email match overrides fuzzy search" do + # Create two members + {:ok, member1} = + Membership.create_member(%{ + first_name: "John", + last_name: "Doe", + email: "john@example.com" + }) + + {:ok, _member2} = + Membership.create_member(%{ + first_name: "Jane", + last_name: "Smith", + email: "jane@example.com" + }) + + # Search with user_email that matches member1, but search_query that would match member2 + query = + Mv.Membership.Member + |> Ash.Query.for_read(:available_for_linking, %{ + user_email: "john@example.com", + search_query: "Jane" + }) + + {:ok, members} = Ash.read(query, domain: Mv.Membership) + + # Apply email filter + filtered_members = Mv.Membership.Member.filter_by_email_match(members, "john@example.com") + + # Should only return member1 (email match takes precedence) + assert length(filtered_members) == 1 + assert hd(filtered_members).id == member1.id + end + + test "limits to 10 results" do + # Create 15 members with similar names + for i <- 1..15 do + Membership.create_member(%{ + first_name: "Test#{i}", + last_name: "Member", + email: "test#{i}@example.com" + }) + end + + # Search for "Test" + query = + Mv.Membership.Member + |> Ash.Query.for_read(:available_for_linking, %{ + user_email: nil, + search_query: "Test" + }) + + {:ok, members} = Ash.read(query, domain: Mv.Membership) + + # Should return max 10 members + assert length(members) == 10 + end + + test "excludes linked members" do + # Create member and link to user + {:ok, member1} = + Membership.create_member(%{ + first_name: "Linked", + last_name: "Member", + email: "linked@example.com" + }) + + {:ok, _user} = + Accounts.create_user(%{ + email: "user@example.com", + member: %{id: member1.id} + }) + + # Create unlinked member + {:ok, member2} = + Membership.create_member(%{ + first_name: "Unlinked", + last_name: "Member", + email: "unlinked@example.com" + }) + + # Search for "Member" + query = + Mv.Membership.Member + |> Ash.Query.for_read(:available_for_linking, %{ + user_email: nil, + search_query: "Member" + }) + + {:ok, members} = Ash.read(query, domain: Mv.Membership) + + # Should only return unlinked member + member_ids = Enum.map(members, & &1.id) + refute member1.id in member_ids + assert member2.id in member_ids + end + end +end From 39b285a714bc23a672a9ad7112750bbce3aec3a9 Mon Sep 17 00:00:00 2001 From: Moritz Date: Thu, 20 Nov 2025 15:52:12 +0100 Subject: [PATCH 02/26] feat: add member fuzzy search for linking (#168) --- lib/membership/member.ex | 77 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 76 insertions(+), 1 deletion(-) diff --git a/lib/membership/member.ex b/lib/membership/member.ex index eeb12c9..8464388 100644 --- a/lib/membership/member.ex +++ b/lib/membership/member.ex @@ -152,7 +152,8 @@ defmodule Mv.Membership.Member do prepare fn query, _ctx -> q = Ash.Query.get_argument(query, :query) || "" - # 0.2 as similarity threshold (recommended) - lower value can lead to more results but also to more unspecific results + # 0.2 as similarity threshold (recommended) + # Lower value can lead to more results but also to more unspecific results threshold = Ash.Query.get_argument(query, :similarity_threshold) || 0.2 if is_binary(q) and String.trim(q) != "" do @@ -187,8 +188,82 @@ defmodule Mv.Membership.Member do end end end + + # Action to find members available for linking to a user account + # Returns only unlinked members (user_id == nil), limited to 10 results + # + # Special behavior for email matching: + # - When user_email AND search_query are both provided: filter by email (email takes precedence) + # - When only user_email provided: return all unlinked members (caller should use filter_by_email_match helper) + # - When only search_query provided: filter by search terms + read :available_for_linking do + argument :user_email, :string, allow_nil?: true + argument :search_query, :string, allow_nil?: true + + prepare fn query, _ctx -> + user_email = Ash.Query.get_argument(query, :user_email) + search_query = Ash.Query.get_argument(query, :search_query) + + # Start with base filter: only unlinked members + base_query = Ash.Query.filter(query, is_nil(user)) + + # Determine filtering strategy + # Priority: search_query (if present) > no filters + # user_email is used for POST-filtering via filter_by_email_match helper + if not is_nil(search_query) and String.trim(search_query) != "" do + # Search query present: Use fuzzy search (regardless of user_email) + trimmed = String.trim(search_query) + + # Use same fuzzy search as :search action (PostgreSQL Trigram + FTS) + base_query + |> Ash.Query.filter( + expr( + # Full-text search + # Trigram similarity for names + # Exact substring match for email + fragment("search_vector @@ websearch_to_tsquery('simple', ?)", ^trimmed) or + fragment("search_vector @@ plainto_tsquery('simple', ?)", ^trimmed) or + fragment("? % first_name", ^trimmed) or + fragment("? % last_name", ^trimmed) or + fragment("word_similarity(?, first_name) > 0.2", ^trimmed) or + fragment("word_similarity(?, last_name) > 0.2", ^trimmed) or + fragment("similarity(first_name, ?) > 0.2", ^trimmed) or + fragment("similarity(last_name, ?) > 0.2", ^trimmed) or + contains(email, ^trimmed) + ) + ) + |> Ash.Query.limit(10) + else + # No search query: return all unlinked members + # Caller should use filter_by_email_match helper for email match logic + base_query + |> Ash.Query.limit(10) + end + end + end end + # Public helper function to apply email match logic after query execution + # This should be called after using :available_for_linking with user_email argument + # + # If a member with matching email exists, returns only that member + # Otherwise returns all members (no filtering) + def filter_by_email_match(members, user_email) + when is_list(members) and is_binary(user_email) do + # Check if any member matches the email + email_match = Enum.find(members, &(&1.email == user_email)) + + if email_match do + # Return only the email-matched member + [email_match] + else + # No email match, return all members + members + end + end + + def filter_by_email_match(members, _user_email), do: members + validations do # Required fields are covered by allow_nil? false From 52a62bd67985ba76bd8db2f246a890c88bdbd395 Mon Sep 17 00:00:00 2001 From: Moritz Date: Thu, 20 Nov 2025 15:52:30 +0100 Subject: [PATCH 03/26] fix: extract member_id from relationship changes during validation (#168) --- .../email_not_used_by_other_member.ex | 23 +++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/lib/mv/accounts/user/validations/email_not_used_by_other_member.ex b/lib/mv/accounts/user/validations/email_not_used_by_other_member.ex index 9cea265..af68f96 100644 --- a/lib/mv/accounts/user/validations/email_not_used_by_other_member.ex +++ b/lib/mv/accounts/user/validations/email_not_used_by_other_member.ex @@ -41,18 +41,37 @@ defmodule Mv.Accounts.User.Validations.EmailNotUsedByOtherMember do if should_validate? do case Ash.Changeset.fetch_change(changeset, :email) do {:ok, new_email} -> - check_email_uniqueness(new_email, member_id) + # Extract member_id from relationship changes for new links + member_id_to_exclude = get_member_id_from_changeset(changeset) + check_email_uniqueness(new_email, member_id_to_exclude) :error -> # No email change, get current email current_email = Ash.Changeset.get_attribute(changeset, :email) - check_email_uniqueness(current_email, member_id) + # Extract member_id from relationship changes for new links + member_id_to_exclude = get_member_id_from_changeset(changeset) + check_email_uniqueness(current_email, member_id_to_exclude) end else :ok end end + # Extract member_id from changeset, checking relationship changes first + # This is crucial for new links where member_id is in manage_relationship changes + defp get_member_id_from_changeset(changeset) do + # Try to get from relationships (for new links via manage_relationship) + case Map.get(changeset.relationships, :member) do + [{[%{id: id}], _opts}] when not is_nil(id) -> + # Found in relationships - this is a new link + id + + _ -> + # Fall back to attribute (for existing links) + Ash.Changeset.get_attribute(changeset, :member_id) + end + end + defp check_email_uniqueness(email, exclude_member_id) do query = Mv.Membership.Member From af193840e2fe47b2e067780ca578f6f27156c62e Mon Sep 17 00:00:00 2001 From: Moritz Date: Thu, 20 Nov 2025 15:55:50 +0100 Subject: [PATCH 04/26] feat: add user-member linking UI with autocomplete (#168) --- assets/js/app.js | 10 + lib/mv_web/live/user_live/form.ex | 260 +++++++++++++++++++++- lib/mv_web/live/user_live/index.ex | 2 +- lib/mv_web/live/user_live/index.html.heex | 7 + 4 files changed, 271 insertions(+), 8 deletions(-) diff --git a/assets/js/app.js b/assets/js/app.js index d5e278a..9b95296 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -23,11 +23,21 @@ import {LiveSocket} from "phoenix_live_view" import topbar from "../vendor/topbar" let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content") + let liveSocket = new LiveSocket("/live", Socket, { longPollFallbackMs: 2500, params: {_csrf_token: csrfToken} }) +// Listen for custom events from LiveView +window.addEventListener("phx:set-input-value", (e) => { + const {id, value} = e.detail + const input = document.getElementById(id) + if (input) { + input.value = value + } +}) + // Show progress bar on live navigation and form submits topbar.config({barColors: {0: "#29d"}, shadowColor: "rgba(0, 0, 0, .3)"}) window.addEventListener("phx:page-loading-start", _info => topbar.show(300)) diff --git a/lib/mv_web/live/user_live/form.ex b/lib/mv_web/live/user_live/form.ex index cf7b687..82df862 100644 --- a/lib/mv_web/live/user_live/form.ex +++ b/lib/mv_web/live/user_live/form.ex @@ -120,6 +120,116 @@ defmodule MvWeb.UserLive.Form do <% end %> <% end %> + + +
+

{gettext("Linked Member")}

+ + <%= if @user && @user.member && !@unlink_member do %> + +
+
+
+

+ {@user.member.first_name} {@user.member.last_name} +

+

{@user.member.email}

+
+ +
+
+ <% else %> + <%= if @unlink_member do %> + +
+

+ {gettext("Unlinking scheduled")}: {gettext( + "Member will be unlinked when you save. Cannot select new member until saved." + )} +

+
+ <% end %> + +
+
+ + + <%= if length(@available_members) > 0 do %> +
+ <%= for member <- @available_members do %> +
+

{member.first_name} {member.last_name}

+

{member.email}

+
+ <% end %> +
+ <% end %> +
+ + <%= if @user && @user.email && @available_members != [] && Enum.all?(@available_members, &(&1.email == to_string(@user.email))) do %> +
+

+ {gettext("Note")}: {gettext( + "A member with this email already exists. To link with a different member, please change one of the email addresses first." + )} +

+
+ <% end %> + + <%= if @selected_member_id && @selected_member_name do %> +
+

+ {gettext("Selected")}: {@selected_member_name} +

+

+ {gettext("Save to confirm linking.")} +

+
+ <% end %> +
+ <% end %> +
<.button phx-disable-with={gettext("Saving...")} variant="primary"> {gettext("Save User")} @@ -135,7 +245,7 @@ defmodule MvWeb.UserLive.Form do user = case params["id"] do nil -> nil - id -> Ash.get!(Mv.Accounts.User, id, domain: Mv.Accounts) + id -> Ash.get!(Mv.Accounts.User, id, domain: Mv.Accounts, load: [:member]) end action = if is_nil(user), do: gettext("New"), else: gettext("Edit") @@ -147,6 +257,13 @@ defmodule MvWeb.UserLive.Form do |> assign(user: user) |> assign(:page_title, page_title) |> assign(:show_password_fields, false) + |> assign(:member_search_query, "") + |> assign(:available_members, []) + |> assign(:show_member_dropdown, false) + |> assign(:selected_member_id, nil) + |> assign(:selected_member_name, nil) + |> assign(:unlink_member, false) + |> load_initial_members() |> assign_form()} end @@ -170,22 +287,102 @@ defmodule MvWeb.UserLive.Form do end def handle_event("save", %{"user" => user_params}, socket) do + # First save the user without member changes case AshPhoenix.Form.submit(socket.assigns.form, params: user_params) do {:ok, user} -> - notify_parent({:saved, user}) + # Then handle member linking/unlinking as a separate step + result = + cond do + # Selected member ID takes precedence (new link) + socket.assigns.selected_member_id -> + Mv.Accounts.update_user(user, %{member: %{id: socket.assigns.selected_member_id}}) - socket = - socket - |> put_flash(:info, "User #{socket.assigns.form.source.type}d successfully") - |> push_navigate(to: return_path(socket.assigns.return_to, user)) + # Unlink flag is set + socket.assigns[:unlink_member] -> + Mv.Accounts.update_user(user, %{member: nil}) - {:noreply, socket} + # No changes to member relationship + true -> + {:ok, user} + end + + case result do + {:ok, updated_user} -> + notify_parent({:saved, updated_user}) + + socket = + socket + |> put_flash(:info, "User #{socket.assigns.form.source.type}d successfully") + |> push_navigate(to: return_path(socket.assigns.return_to, updated_user)) + + {:noreply, socket} + + {:error, error} -> + # Show error from member linking/unlinking + {:noreply, + put_flash(socket, :error, "Failed to update member relationship: #{inspect(error)}")} + end {:error, form} -> {:noreply, assign(socket, form: form)} end end + def handle_event("show_member_dropdown", _params, socket) do + {:noreply, assign(socket, show_member_dropdown: true)} + end + + def handle_event("hide_member_dropdown", _params, socket) do + {:noreply, assign(socket, show_member_dropdown: false)} + end + + def handle_event("search_members", %{"member_search" => query}, socket) do + socket = + socket + |> assign(:member_search_query, query) + |> load_available_members(query) + |> assign(:show_member_dropdown, true) + + {:noreply, socket} + end + + def handle_event("select_member", %{"id" => member_id}, socket) do + # Find the selected member to get their name + selected_member = Enum.find(socket.assigns.available_members, &(&1.id == member_id)) + + member_name = + if selected_member, + do: "#{selected_member.first_name} #{selected_member.last_name}", + else: "" + + # Store the selected member ID and name in socket state and clear unlink flag + socket = + socket + |> assign(:selected_member_id, member_id) + |> assign(:selected_member_name, member_name) + |> assign(:unlink_member, false) + |> assign(:show_member_dropdown, false) + |> assign(:member_search_query, member_name) + |> push_event("set-input-value", %{id: "member-search-input", value: member_name}) + + {:noreply, socket} + end + + def handle_event("unlink_member", _params, socket) do + # Set flag to unlink member on save + # Clear all member selection state and keep dropdown hidden + socket = + socket + |> assign(:unlink_member, true) + |> assign(:selected_member_id, nil) + |> assign(:selected_member_name, nil) + |> assign(:member_search_query, "") + |> assign(:show_member_dropdown, false) + |> load_initial_members() + + {:noreply, socket} + end + defp notify_parent(msg), do: send(self(), {__MODULE__, msg}) defp assign_form(%{assigns: %{user: user, show_password_fields: show_password_fields}} = socket) do @@ -209,4 +406,53 @@ defmodule MvWeb.UserLive.Form do defp return_path("index", _user), do: ~p"/users" defp return_path("show", user), do: ~p"/users/#{user.id}" + + # Load initial members when the form is loaded or member is unlinked + defp load_initial_members(socket) do + user = socket.assigns.user + user_email = if user, do: user.email, else: nil + + members = load_members_for_linking(user_email, "") + + # Dropdown should ALWAYS be hidden initially + # It will only show when user focuses the input field (show_member_dropdown event) + socket + |> assign(available_members: members) + |> assign(show_member_dropdown: false) + end + + # Load members based on search query + defp load_available_members(socket, query) do + user = socket.assigns.user + user_email = if user, do: user.email, else: nil + + members = load_members_for_linking(user_email, query) + assign(socket, available_members: members) + end + + # Query available members using the Ash action + defp load_members_for_linking(user_email, search_query) do + user_email_str = if user_email, do: to_string(user_email), else: nil + search_query_str = if search_query && search_query != "", do: search_query, else: nil + + query = + Mv.Membership.Member + |> Ash.Query.for_read(:available_for_linking, %{ + user_email: user_email_str, + search_query: search_query_str + }) + + case Ash.read(query, domain: Mv.Membership) do + {:ok, members} -> + # Apply email match filter if user_email is provided + if user_email_str do + Mv.Membership.Member.filter_by_email_match(members, user_email_str) + else + members + end + + {:error, _} -> + [] + end + end end diff --git a/lib/mv_web/live/user_live/index.ex b/lib/mv_web/live/user_live/index.ex index 8803237..0c1d7be 100644 --- a/lib/mv_web/live/user_live/index.ex +++ b/lib/mv_web/live/user_live/index.ex @@ -25,7 +25,7 @@ defmodule MvWeb.UserLive.Index do @impl true def mount(_params, _session, socket) do - users = Ash.read!(Mv.Accounts.User, domain: Mv.Accounts) + users = Ash.read!(Mv.Accounts.User, domain: Mv.Accounts, load: [:member]) sorted = Enum.sort_by(users, & &1.email) {:ok, diff --git a/lib/mv_web/live/user_live/index.html.heex b/lib/mv_web/live/user_live/index.html.heex index 66e3b9e..3582046 100644 --- a/lib/mv_web/live/user_live/index.html.heex +++ b/lib/mv_web/live/user_live/index.html.heex @@ -50,6 +50,13 @@ {user.email} <:col :let={user} label={gettext("OIDC ID")}>{user.oidc_id} + <:col :let={user} label={gettext("Linked Member")}> + <%= if user.member do %> + {user.member.first_name} {user.member.last_name} + <% else %> + {gettext("No member linked")} + <% end %> + <:action :let={user}>
From 48b082309173ca1e90603ee0975f1f4b8cf158b3 Mon Sep 17 00:00:00 2001 From: Moritz Date: Thu, 20 Nov 2025 15:56:05 +0100 Subject: [PATCH 05/26] test: add LiveView tests for member linking UI (#168) --- .../user_live/form_member_linking_ui_test.exs | 433 ++++++++++++++++++ test/mv_web/user_live/form_test.exs | 97 ++++ test/mv_web/user_live/index_test.exs | 31 ++ 3 files changed, 561 insertions(+) create mode 100644 test/mv_web/user_live/form_member_linking_ui_test.exs diff --git a/test/mv_web/user_live/form_member_linking_ui_test.exs b/test/mv_web/user_live/form_member_linking_ui_test.exs new file mode 100644 index 0000000..280dca9 --- /dev/null +++ b/test/mv_web/user_live/form_member_linking_ui_test.exs @@ -0,0 +1,433 @@ +defmodule MvWeb.UserLive.FormMemberLinkingUiTest do + @moduledoc """ + UI tests for member linking in UserLive.Form. + Tests dropdown behavior, fuzzy search, selection, and unlink workflow. + Related to Issue #168. + """ + + use MvWeb.ConnCase, async: true + + import Phoenix.LiveViewTest + + alias Mv.Accounts + alias Mv.Membership + + # Helper to setup authenticated connection for admin + defp setup_admin_conn(conn) do + conn_with_oidc_user(conn, %{email: "admin@example.com"}) + end + + describe "dropdown visibility" do + test "dropdown hidden on mount", %{conn: conn} do + conn = setup_admin_conn(conn) + html = conn |> live(~p"/users/new") |> render() + + # Dropdown should not be visible initially + refute html =~ ~r/role="listbox"/ + end + + test "dropdown shows after focus event", %{conn: conn} do + conn = setup_admin_conn(conn) + # Create unlinked members + create_unlinked_members(3) + + {:ok, view, _html} = live(conn, ~p"/users/new") + + # Focus the member search input + view + |> element("#member-search-input") + |> render_focus() + + html = render(view) + + # Dropdown should now be visible + assert html =~ ~r/role="listbox"/ + end + + test "dropdown shows top 10 unlinked members on focus", %{conn: conn} do + # Create 15 unlinked members + members = create_unlinked_members(15) + + {:ok, view, _html} = live(conn, ~p"/users/new") + + # Focus the member search input + view + |> element("#member-search-input") + |> render_focus() + + html = render(view) + + # Should show only 10 members + shown_members = Enum.take(members, 10) + hidden_members = Enum.drop(members, 10) + + for member <- shown_members do + assert html =~ member.first_name + end + + for member <- hidden_members do + refute html =~ member.first_name + end + end + end + + describe "fuzzy search" do + test "finds member with exact name", %{conn: conn} do + {:ok, member} = + Membership.create_member(%{ + first_name: "Jonathan", + last_name: "Smith", + email: "jonathan.smith@example.com" + }) + + {:ok, view, _html} = live(conn, ~p"/users/new") + + # Type exact name + view + |> element("#member-search-input") + |> render_change(%{"member_search_query" => "Jonathan"}) + + html = render(view) + + assert html =~ "Jonathan" + assert html =~ "Smith" + end + + test "finds member with typo (Jon finds Jonathan)", %{conn: conn} do + {:ok, member} = + Membership.create_member(%{ + first_name: "Jonathan", + last_name: "Smith", + email: "jonathan.smith@example.com" + }) + + {:ok, view, _html} = live(conn, ~p"/users/new") + + # Type with typo + view + |> element("#member-search-input") + |> render_change(%{"member_search_query" => "Jon"}) + + html = render(view) + + # Fuzzy search should find Jonathan + assert html =~ "Jonathan" + assert html =~ "Smith" + end + + test "finds member with partial substring", %{conn: conn} do + {:ok, member} = + Membership.create_member(%{ + first_name: "Alexander", + last_name: "Williams", + email: "alex@example.com" + }) + + {:ok, view, _html} = live(conn, ~p"/users/new") + + # Type partial + view + |> element("#member-search-input") + |> render_change(%{"member_search_query" => "lex"}) + + html = render(view) + + assert html =~ "Alexander" + end + + test "returns empty for no matches", %{conn: conn} do + {:ok, member} = + Membership.create_member(%{ + first_name: "John", + last_name: "Doe", + email: "john@example.com" + }) + + {:ok, view, _html} = live(conn, ~p"/users/new") + + # Type something that doesn't match + view + |> element("#member-search-input") + |> render_change(%{"member_search_query" => "zzzzzzz"}) + + html = render(view) + + refute html =~ "John" + end + end + + describe "member selection" do + test "input field shows selected member name", %{conn: conn} do + {:ok, member} = + Membership.create_member(%{ + first_name: "Alice", + last_name: "Johnson", + email: "alice@example.com" + }) + + {:ok, view, _html} = live(conn, ~p"/users/new") + + # Focus and search + view + |> element("#member-search-input") + |> render_focus() + + # Select member + view + |> element("[data-member-id='#{member.id}']") + |> render_click() + + html = render(view) + + # Input field should show member name + assert html =~ "Alice Johnson" + end + + test "confirmation box appears", %{conn: conn} do + {:ok, member} = + Membership.create_member(%{ + first_name: "Bob", + last_name: "Williams", + email: "bob@example.com" + }) + + {:ok, view, _html} = live(conn, ~p"/users/new") + + # Focus input + view + |> element("#member-search-input") + |> render_focus() + + # Select member + view + |> element("[data-member-id='#{member.id}']") + |> render_click() + + html = render(view) + + # Confirmation box should appear + assert html =~ "Selected" + assert html =~ "Bob Williams" + assert html =~ "Save to confirm linking" + end + + test "hidden input stores member ID", %{conn: conn} do + {:ok, member} = + Membership.create_member(%{ + first_name: "Charlie", + last_name: "Brown", + email: "charlie@example.com" + }) + + {:ok, view, _html} = live(conn, ~p"/users/new") + + # Focus input + view + |> element("#member-search-input") + |> render_focus() + + # Select member + view + |> element("[data-member-id='#{member.id}']") + |> render_click() + + # Check socket assigns (member ID should be stored) + assert view |> element("#user-form") |> has_element?() + end + end + + describe "email handling" do + test "links user and member with identical email successfully", %{conn: conn} do + {:ok, member} = + Membership.create_member(%{ + first_name: "David", + last_name: "Miller", + email: "david@example.com" + }) + + {:ok, view, _html} = live(conn, ~p"/users/new") + + # Fill user form with same email + view + |> form("#user-form", user: %{email: "david@example.com"}) + |> render_change() + + # Focus input + view + |> element("#member-search-input") + |> render_focus() + + # Select member + view + |> element("[data-member-id='#{member.id}']") + |> render_click() + + # Submit form + view + |> form("#user-form", user: %{email: "david@example.com"}) + |> render_submit() + + # Should succeed without errors + assert_redirected(view, ~p"/users") + end + + test "shows info when member has same email", %{conn: conn} do + {:ok, member} = + Membership.create_member(%{ + first_name: "Emma", + last_name: "Davis", + email: "emma@example.com" + }) + + {:ok, view, _html} = live(conn, ~p"/users/new") + + # Fill user form with same email + view + |> form("#user-form", user: %{email: "emma@example.com"}) + |> render_change() + + html = render(view) + + # Should show info message about email conflict + assert html =~ "A member with this email already exists" + end + end + + describe "unlink workflow" do + test "unlink hides dropdown", %{conn: conn} do + # Create user with linked member + {:ok, member} = + Membership.create_member(%{ + first_name: "Frank", + last_name: "Wilson", + email: "frank@example.com" + }) + + {:ok, user} = + Accounts.create_user(%{ + email: "frank@example.com", + member: %{id: member.id} + }) + + {:ok, view, _html} = live(conn, ~p"/users/#{user.id}/edit") + + # Click unlink button + view + |> element("button[phx-click='unlink_member']") + |> render_click() + + html = render(view) + + # Dropdown should not be visible + refute html =~ ~r/role="listbox"/ + end + + test "unlink shows warning", %{conn: conn} do + # Create user with linked member + {:ok, member} = + Membership.create_member(%{ + first_name: "Grace", + last_name: "Taylor", + email: "grace@example.com" + }) + + {:ok, user} = + Accounts.create_user(%{ + email: "grace@example.com", + member: %{id: member.id} + }) + + {:ok, view, _html} = live(conn, ~p"/users/#{user.id}/edit") + + # Click unlink button + view + |> element("button[phx-click='unlink_member']") + |> render_click() + + html = render(view) + + # Should show warning + assert html =~ "Unlinking scheduled" + assert html =~ "Cannot select new member until saved" + end + + test "unlink disables input", %{conn: conn} do + # Create user with linked member + {:ok, member} = + Membership.create_member(%{ + first_name: "Henry", + last_name: "Anderson", + email: "henry@example.com" + }) + + {:ok, user} = + Accounts.create_user(%{ + email: "henry@example.com", + member: %{id: member.id} + }) + + {:ok, view, _html} = live(conn, ~p"/users/#{user.id}/edit") + + # Click unlink button + view + |> element("button[phx-click='unlink_member']") + |> render_click() + + html = render(view) + + # Input should be disabled + assert html =~ ~r/disabled/ + end + + test "save re-enables member selection", %{conn: conn} do + # Create user with linked member + {:ok, member} = + Membership.create_member(%{ + first_name: "Isabel", + last_name: "Martinez", + email: "isabel@example.com" + }) + + {:ok, user} = + Accounts.create_user(%{ + email: "isabel@example.com", + member: %{id: member.id} + }) + + {:ok, view, _html} = live(conn, ~p"/users/#{user.id}/edit") + + # Click unlink button + view + |> element("button[phx-click='unlink_member']") + |> render_click() + + # Submit form + view + |> form("#user-form") + |> render_submit() + + # Navigate back to edit + {:ok, view, _html} = live(conn, ~p"/users/#{user.id}/edit") + + html = render(view) + + # Should now show member selection input (not disabled) + assert html =~ "member-search-input" + refute html =~ "Unlinking scheduled" + end + end + + # Helper functions + defp create_unlinked_members(count) do + for i <- 1..count do + {:ok, member} = + Membership.create_member(%{ + first_name: "FirstName#{i}", + last_name: "LastName#{i}", + email: "member#{i}@example.com" + }) + + member + end + end +end diff --git a/test/mv_web/user_live/form_test.exs b/test/mv_web/user_live/form_test.exs index 111ff42..b8f7313 100644 --- a/test/mv_web/user_live/form_test.exs +++ b/test/mv_web/user_live/form_test.exs @@ -281,4 +281,101 @@ defmodule MvWeb.UserLive.FormTest do assert edit_html =~ "Change Password" end end + + describe "member linking - display" do + test "shows linked member with unlink button when user has member", %{conn: conn} do + # Create member + {:ok, member} = + Mv.Membership.create_member(%{ + first_name: "John", + last_name: "Doe", + email: "john@example.com" + }) + + # Create user linked to member + user = create_test_user(%{email: "user@example.com"}) + {:ok, _updated_user} = Mv.Accounts.update_user(user, %{member: %{id: member.id}}) + + # Load form + {:ok, view, html} = setup_live_view(conn, "/users/#{user.id}/edit") + + # Should show linked member section + assert html =~ "Linked Member" + assert html =~ "John Doe" + assert html =~ "user@example.com" + assert has_element?(view, "button[phx-click='unlink_member']") + assert html =~ "Unlink Member" + end + + test "shows member search field when user has no member", %{conn: conn} do + user = create_test_user(%{email: "user@example.com"}) + {:ok, view, html} = setup_live_view(conn, "/users/#{user.id}/edit") + + # Should show member search section + assert html =~ "Linked Member" + assert has_element?(view, "input[phx-change='search_members']") + # Should not show unlink button + refute has_element?(view, "button[phx-click='unlink_member']") + end + end + + describe "member linking - workflow" do + test "selecting member and saving links member to user", %{conn: conn} do + # Create unlinked member + {:ok, member} = + Mv.Membership.create_member(%{ + first_name: "Jane", + last_name: "Smith", + email: "jane@example.com" + }) + + # Create user without member + user = create_test_user(%{email: "user@example.com"}) + {:ok, view, _html} = setup_live_view(conn, "/users/#{user.id}/edit") + + # Select member + view |> element("div[data-member-id='#{member.id}']") |> render_click() + + # Submit form + view + |> form("#user-form", user: %{email: "user@example.com"}) + |> render_submit() + + assert_redirected(view, "/users") + + # Verify member is linked + updated_user = Ash.get!(Mv.Accounts.User, user.id, domain: Mv.Accounts, load: [:member]) + assert updated_user.member.id == member.id + end + + test "unlinking member and saving removes member from user", %{conn: conn} do + # Create member + {:ok, member} = + Mv.Membership.create_member(%{ + first_name: "Bob", + last_name: "Wilson", + email: "bob@example.com" + }) + + # Create user linked to member + user = create_test_user(%{email: "user@example.com"}) + {:ok, _} = Mv.Accounts.update_user(user, %{member: %{id: member.id}}) + + {:ok, view, _html} = setup_live_view(conn, "/users/#{user.id}/edit") + + # Click unlink button + view |> element("button[phx-click='unlink_member']") |> render_click() + + # Submit form + view + |> form("#user-form", user: %{email: "user@example.com"}) + |> render_submit() + + assert_redirected(view, "/users") + + # Verify member is unlinked + updated_user = Ash.get!(Mv.Accounts.User, user.id, domain: Mv.Accounts, load: [:member]) + assert is_nil(updated_user.member) + end + end end diff --git a/test/mv_web/user_live/index_test.exs b/test/mv_web/user_live/index_test.exs index 6393e3b..c0b0275 100644 --- a/test/mv_web/user_live/index_test.exs +++ b/test/mv_web/user_live/index_test.exs @@ -410,4 +410,35 @@ defmodule MvWeb.UserLive.IndexTest do assert html =~ long_email end end + + describe "member linking display" do + test "displays linked member name in user list", %{conn: conn} do + # Create member + {:ok, member} = + Mv.Membership.create_member(%{ + first_name: "Alice", + last_name: "Johnson", + email: "alice@example.com" + }) + + # Create user linked to member + user = create_test_user(%{email: "user@example.com"}) + {:ok, _updated_user} = Mv.Accounts.update_user(user, %{member: %{id: member.id}}) + + # Create another user without member + _unlinked_user = create_test_user(%{email: "unlinked@example.com"}) + + conn = conn_with_oidc_user(conn) + {:ok, _view, html} = live(conn, "/users") + + # Should show linked member name + assert html =~ "Alice Johnson" + # Should show user email + assert html =~ "user@example.com" + # Should show unlinked user + assert html =~ "unlinked@example.com" + # Should show "No member linked" or similar for unlinked user + assert html =~ "No member linked" + end + end end From 078809981d47b93a5bb8811e96af12ed062ef57e Mon Sep 17 00:00:00 2001 From: Moritz Date: Thu, 20 Nov 2025 15:57:10 +0100 Subject: [PATCH 06/26] docs: add translations and update development log (#168) --- CHANGELOG.md | 19 ++++ docs/development-progress-log.md | 129 +++++++++++++++++++++++++ mix.lock | 4 +- priv/gettext/de/LC_MESSAGES/default.po | 72 +++++++++++--- priv/gettext/default.pot | 69 +++++++++++-- priv/gettext/en/LC_MESSAGES/default.po | 72 +++++++++++--- 6 files changed, 325 insertions(+), 40 deletions(-) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..74df997 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,19 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added +- User-Member linking with fuzzy search autocomplete (#168) +- PostgreSQL trigram-based member search with typo tolerance +- WCAG 2.1 AA compliant autocomplete dropdown with ARIA support +- Bilingual UI (German/English) for member linking workflow + +### Fixed +- Email validation false positive when linking user and member with identical emails (#168 Problem #4) +- Relationship data extraction from Ash manage_relationship during validation + diff --git a/docs/development-progress-log.md b/docs/development-progress-log.md index f7447f2..1b86106 100644 --- a/docs/development-progress-log.md +++ b/docs/development-progress-log.md @@ -1321,6 +1321,135 @@ end --- +## Session: User-Member Linking UI Enhancement (2025-01-13) + +### Feature Summary +Implemented user-member linking functionality in User Edit/Create views with fuzzy search autocomplete, email conflict handling, and accessibility support. + +**Key Features:** +- Autocomplete dropdown with PostgreSQL Trigram fuzzy search +- Link/unlink members to user accounts +- Email synchronization between linked entities +- WCAG 2.1 AA compliant (ARIA labels) +- Bilingual UI (English/German) + +### Technical Decisions + +**1. Search Priority Logic** +Search query takes precedence over email filtering to provide better UX: +- User types → fuzzy search across all unlinked members +- Email matching only used for post-filtering when no search query present + +**2. JavaScript Hook for Input Value** +Used minimal JavaScript (~6 lines) for reliable input field updates: +```javascript +// assets/js/app.js +window.addEventListener("phx:set-input-value", (e) => { + document.getElementById(e.detail.id).value = e.detail.value +}) +``` +**Rationale:** LiveView DOM patching has race conditions with rapid state changes in autocomplete components. Direct DOM manipulation via `push_event` is the idiomatic LiveView solution for this edge case. + +**3. Fuzzy Search Implementation** +Combined PostgreSQL Full-Text Search + Trigram for optimal results: +```sql +-- FTS for exact word matching +search_vector @@ websearch_to_tsquery('simple', 'greta') +-- Trigram for typo tolerance +word_similarity('gre', first_name) > 0.2 +-- Substring for email/IDs +email ILIKE '%greta%' +``` + +### Key Learnings + +#### 1. Ash `manage_relationship` Internals +**Critical Discovery:** During validation, relationship data lives in `changeset.relationships`, NOT `changeset.attributes`: + +```elixir +# During validation (manage_relationship processing): +changeset.relationships.member = [{[%{id: "uuid"}], opts}] +changeset.attributes.member_id = nil # Still nil! + +# After action completes: +changeset.attributes.member_id = "uuid" # Now set +``` + +**Solution:** Extract member_id from both sources: +```elixir +defp get_member_id_from_changeset(changeset) do + case Map.get(changeset.relationships, :member) do + [{[%{id: id}], _opts}] -> id # New link + _ -> Ash.Changeset.get_attribute(changeset, :member_id) # Existing + end +end +``` + +**Impact:** Fixed email validation false positives when linking user+member with identical emails. + +#### 2. LiveView + JavaScript Integration Patterns + +**When to use JavaScript:** +- ✅ Direct DOM manipulation (autocomplete, input values) +- ✅ Browser APIs (clipboard, geolocation) +- ✅ Third-party libraries + +**When NOT to use JavaScript:** +- ❌ Form submissions +- ❌ Simple show/hide logic +- ❌ Server-side data fetching + +**Pattern:** +```elixir +socket |> push_event("event-name", %{key: value}) +``` +```javascript +window.addEventListener("phx:event-name", (e) => { /* handle */ }) +``` + +#### 3. PostgreSQL Trigram Search +Requires `pg_trgm` extension with GIN indexes: +```sql +CREATE INDEX members_first_name_trgm_idx + ON members USING GIN(first_name gin_trgm_ops); +``` +Supports: +- Typo tolerance: "Gret" finds "Greta" +- Partial matching: "Mit" finds "Mitglied" +- Substring: "exam" finds "example.com" + +#### 4. Test-Driven Development for Bug Fixes +Effective workflow: +1. Write test that reproduces bug (should fail) +2. Implement minimal fix +3. Verify test passes +4. Refactor while green + +**Result:** 355 tests passing, 100% backend coverage for new features. + +### Files Changed + +**Backend:** +- `lib/membership/member.ex` - `:available_for_linking` action with fuzzy search +- `lib/mv/accounts/user/validations/email_not_used_by_other_member.ex` - Relationship change extraction +- `lib/mv_web/live/user_live/form.ex` - Event handlers, state management + +**Frontend:** +- `assets/js/app.js` - Input value hook (6 lines) +- `priv/gettext/**/*.po` - 10 new translation keys (DE/EN) + +**Tests (NEW):** +- `test/membership/member_fuzzy_search_linking_test.exs` +- `test/accounts/user_member_linking_email_test.exs` +- `test/mv_web/user_live/form_member_linking_ui_test.exs` + +### Deployment Notes +- **Assets:** Requires `cd assets && npm run build` +- **Database:** No migrations (uses existing indexes) +- **Config:** No changes required + +--- + ## Conclusion This project demonstrates a modern Phoenix application built with: diff --git a/mix.lock b/mix.lock index 28683a3..77dcc09 100644 --- a/mix.lock +++ b/mix.lock @@ -16,7 +16,7 @@ "comeonin": {:hex, :comeonin, "5.5.1", "5113e5f3800799787de08a6e0db307133850e635d34e9fab23c70b6501669510", [:mix], [], "hexpm", "65aac8f19938145377cee73973f192c5645873dcf550a8a6b18187d17c13ccdb"}, "credo": {:hex, :credo, "1.7.13", "126a0697df6b7b71cd18c81bc92335297839a806b6f62b61d417500d1070ff4e", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "47641e6d2bbff1e241e87695b29f617f1a8f912adea34296fb10ecc3d7e9e84f"}, "crux": {:hex, :crux, "0.1.1", "94f2f97d2a6079ae3c57f356412bc3b307f9579a80e43f526447b1d508dd4a72", [:mix], [{:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}, {:simple_sat, ">= 0.1.1 and < 1.0.0-0", [hex: :simple_sat, repo: "hexpm", optional: true]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: true]}], "hexpm", "e59d498f038193cbe31e448f9199f5b4c53a4c67cece9922bb839595189dd2b6"}, - "db_connection": {:hex, :db_connection, "2.8.1", "9abdc1e68c34c6163f6fb96a96532272d13ad7ca45262156ae8b7ec6d9dc4bec", [], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a61a3d489b239d76f326e03b98794fb8e45168396c925ef25feb405ed09da8fd"}, + "db_connection": {:hex, :db_connection, "2.8.1", "9abdc1e68c34c6163f6fb96a96532272d13ad7ca45262156ae8b7ec6d9dc4bec", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a61a3d489b239d76f326e03b98794fb8e45168396c925ef25feb405ed09da8fd"}, "decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"}, "dns_cluster": {:hex, :dns_cluster, "0.2.0", "aa8eb46e3bd0326bd67b84790c561733b25c5ba2fe3c7e36f28e88f384ebcb33", [:mix], [], "hexpm", "ba6f1893411c69c01b9e8e8f772062535a4cf70f3f35bcc964a324078d8c8240"}, "ecto": {:hex, :ecto, "3.13.3", "6a983f0917f8bdc7a89e96f2bf013f220503a0da5d8623224ba987515b3f0d80", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "1927db768f53a88843ff25b6ba7946599a8ca8a055f69ad8058a1432a399af94"}, @@ -80,7 +80,7 @@ "telemetry_metrics": {:hex, :telemetry_metrics, "1.1.0", "5bd5f3b5637e0abea0426b947e3ce5dd304f8b3bc6617039e2b5a008adc02f8f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e7b79e8ddfde70adb6db8a6623d1778ec66401f366e9a8f5dd0955c56bc8ce67"}, "telemetry_poller": {:hex, :telemetry_poller, "1.3.0", "d5c46420126b5ac2d72bc6580fb4f537d35e851cc0f8dbd571acf6d6e10f5ec7", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "51f18bed7128544a50f75897db9974436ea9bfba560420b646af27a9a9b35211"}, "text_diff": {:hex, :text_diff, "0.1.0", "1caf3175e11a53a9a139bc9339bd607c47b9e376b073d4571c031913317fecaa", [:mix], [], "hexpm", "d1ffaaecab338e49357b6daa82e435f877e0649041ace7755583a0ea3362dbd7"}, - "thousand_island": {:hex, :thousand_island, "1.4.1", "8df065e627407e281f7935da5ad0f3842d10eb721afa92e760b720d71e2e37aa", [], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "204a8640e5d2818589b87286ae66160978628d7edf6095181cbe0440765fb6c1"}, + "thousand_island": {:hex, :thousand_island, "1.4.1", "8df065e627407e281f7935da5ad0f3842d10eb721afa92e760b720d71e2e37aa", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "204a8640e5d2818589b87286ae66160978628d7edf6095181cbe0440765fb6c1"}, "tidewave": {:hex, :tidewave, "0.5.0", "8f278d7eb2d0af36ae6d4f73a5872bd066815bd57b57401125187ba901f095a4", [:mix], [{:circular_buffer, "~> 0.4 or ~> 1.0", [hex: :circular_buffer, repo: "hexpm", optional: false]}, {:igniter, "~> 0.6", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix_live_reload, ">= 1.6.1", [hex: :phoenix_live_reload, repo: "hexpm", optional: true]}, {:plug, "~> 1.17", [hex: :plug, repo: "hexpm", optional: false]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}], "hexpm", "9a1eb5d2f12ff4912328dfbfe652c27fded462c6ed6fd11814ee28d3e9d016b4"}, "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.1", "a48703a25c170eedadca83b11e88985af08d35f37c6f664d6dcfb106a97782fc", [:rebar3], [], "hexpm", "b3a917854ce3ae233619744ad1e0102e05673136776fb2fa76234f3e03b23642"}, "websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"}, diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po index 842ab40..18e1053 100644 --- a/priv/gettext/de/LC_MESSAGES/default.po +++ b/priv/gettext/de/LC_MESSAGES/default.po @@ -16,7 +16,7 @@ msgid "Actions" msgstr "Aktionen" #: lib/mv_web/live/member_live/index.html.heex:200 -#: lib/mv_web/live/user_live/index.html.heex:65 +#: lib/mv_web/live/user_live/index.html.heex:72 #, elixir-autogen, elixir-format msgid "Are you sure?" msgstr "Bist du sicher?" @@ -35,14 +35,14 @@ msgid "City" msgstr "Stadt" #: lib/mv_web/live/member_live/index.html.heex:202 -#: lib/mv_web/live/user_live/index.html.heex:67 +#: 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:194 -#: lib/mv_web/live/user_live/form.ex:141 -#: lib/mv_web/live/user_live/index.html.heex:59 +#: lib/mv_web/live/user_live/form.ex:251 +#: lib/mv_web/live/user_live/index.html.heex:66 #, elixir-autogen, elixir-format msgid "Edit" msgstr "Bearbeite" @@ -88,7 +88,7 @@ msgid "New Member" msgstr "Neues Mitglied" #: lib/mv_web/live/member_live/index.html.heex:191 -#: lib/mv_web/live/user_live/index.html.heex:56 +#: lib/mv_web/live/user_live/index.html.heex:63 #, elixir-autogen, elixir-format msgid "Show" msgstr "Anzeigen" @@ -161,7 +161,7 @@ msgstr "Mitglied speichern" #: lib/mv_web/live/custom_field_live/form.ex:64 #: lib/mv_web/live/custom_field_value_live/form.ex:74 #: lib/mv_web/live/member_live/form.ex:79 -#: lib/mv_web/live/user_live/form.ex:124 +#: lib/mv_web/live/user_live/form.ex:234 #, elixir-autogen, elixir-format msgid "Saving..." msgstr "Speichern..." @@ -256,7 +256,7 @@ msgstr "Ihr Passwort wurde erfolgreich zurückgesetzt" #: lib/mv_web/live/custom_field_live/index.ex:120 #: lib/mv_web/live/custom_field_value_live/form.ex:77 #: lib/mv_web/live/member_live/form.ex:82 -#: lib/mv_web/live/user_live/form.ex:127 +#: lib/mv_web/live/user_live/form.ex:237 #, elixir-autogen, elixir-format msgid "Cancel" msgstr "Abbrechen" @@ -336,6 +336,7 @@ msgstr "Nicht gesetzt" #: lib/mv_web/live/user_live/form.ex:107 #: lib/mv_web/live/user_live/form.ex:115 +#: lib/mv_web/live/user_live/form.ex:210 #, elixir-autogen, elixir-format msgid "Note" msgstr "Hinweis" @@ -376,7 +377,7 @@ msgstr "Mitglied auswählen" msgid "Settings" msgstr "Einstellungen" -#: lib/mv_web/live/user_live/form.ex:125 +#: lib/mv_web/live/user_live/form.ex:235 #, elixir-autogen, elixir-format msgid "Save User" msgstr "Benutzer*in speichern" @@ -401,7 +402,7 @@ msgstr "Nicht unterstützter Wertetyp: %{type}" msgid "Use this form to manage user records in your database." msgstr "Verwenden Sie dieses Formular, um Benutzer*innen-Datensätze zu verwalten." -#: lib/mv_web/live/user_live/form.ex:142 +#: lib/mv_web/live/user_live/form.ex:252 #: lib/mv_web/live/user_live/show.ex:34 #, elixir-autogen, elixir-format msgid "User" @@ -429,7 +430,7 @@ msgstr "aufsteigend" msgid "descending" msgstr "absteigend" -#: lib/mv_web/live/user_live/form.ex:141 +#: lib/mv_web/live/user_live/form.ex:251 #, elixir-autogen, elixir-format msgid "New" msgstr "Neue*r" @@ -504,6 +505,8 @@ msgstr "Passwort setzen" msgid "User will be created without a password. Check 'Set Password' to add one." msgstr "Benutzer*in wird ohne Passwort erstellt. Aktivieren Sie 'Passwort setzen', um eines hinzuzufügen." +#: lib/mv_web/live/user_live/form.ex:126 +#: lib/mv_web/live/user_live/index.html.heex:53 #: lib/mv_web/live/user_live/show.ex:55 #, elixir-autogen, elixir-format msgid "Linked Member" @@ -514,6 +517,7 @@ msgstr "Verknüpftes Mitglied" msgid "Linked User" msgstr "Verknüpfte*r Benutzer*in" +#: lib/mv_web/live/user_live/index.html.heex:57 #: lib/mv_web/live/user_live/show.ex:65 #, elixir-autogen, elixir-format msgid "No member linked" @@ -694,7 +698,47 @@ msgstr "Obigen Text zur Bestätigung eingeben" msgid "To confirm deletion, please enter this text:" msgstr "Um die Löschung zu bestätigen, gib bitte folgenden Text ein:" -#~ #: lib/mv_web/live/custom_field_live/index.ex:97 -#~ #, elixir-autogen, elixir-format -#~ msgid "To confirm deletion, please enter the custom field slug:" -#~ msgstr "Um die Löschung zu bestätigen, gib bitte den Slug des benutzerdefinierten Feldes ein:" +#: lib/mv_web/live/user_live/form.ex:210 +#, elixir-autogen, elixir-format +msgid "A member with this email already exists. To link with a different member, please change one of the email addresses first." +msgstr "Ein Mitglied mit dieser E-Mail-Adresse existiert bereits. Um mit einem anderen Mitglied zu verknüpfen, ändern Sie bitte zuerst eine der E-Mail-Adressen." + +#: lib/mv_web/live/user_live/form.ex:185 +#, elixir-autogen, elixir-format +msgid "Available members" +msgstr "Verfügbare Mitglieder" + +#: lib/mv_web/live/user_live/form.ex:152 +#, elixir-autogen, elixir-format +msgid "Member will be unlinked when you save. Cannot select new member until saved." +msgstr "Mitglied wird beim Speichern entverknüpft. Neues Mitglied kann erst nach dem Speichern ausgewählt werden." + +#: lib/mv_web/live/user_live/form.ex:226 +#, elixir-autogen, elixir-format +msgid "Save to confirm linking." +msgstr "Speichern, um die Verknüpfung zu bestätigen." + +#: lib/mv_web/live/user_live/form.ex:169 +#, elixir-autogen, elixir-format +msgid "Search for a member to link..." +msgstr "Nach einem Mitglied zum Verknüpfen suchen..." + +#: lib/mv_web/live/user_live/form.ex:173 +#, elixir-autogen, elixir-format +msgid "Search for member to link" +msgstr "Nach Mitglied zum Verknüpfen suchen" + +#: lib/mv_web/live/user_live/form.ex:223 +#, elixir-autogen, elixir-format +msgid "Selected" +msgstr "Ausgewählt" + +#: lib/mv_web/live/user_live/form.ex:143 +#, elixir-autogen, elixir-format +msgid "Unlink Member" +msgstr "Mitglied entverknüpfen" + +#: lib/mv_web/live/user_live/form.ex:152 +#, elixir-autogen, elixir-format +msgid "Unlinking scheduled" +msgstr "Entverknüpfung geplant" diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot index 5942951..a87d935 100644 --- a/priv/gettext/default.pot +++ b/priv/gettext/default.pot @@ -17,7 +17,7 @@ msgid "Actions" msgstr "" #: lib/mv_web/live/member_live/index.html.heex:200 -#: lib/mv_web/live/user_live/index.html.heex:65 +#: lib/mv_web/live/user_live/index.html.heex:72 #, elixir-autogen, elixir-format msgid "Are you sure?" msgstr "" @@ -36,14 +36,14 @@ msgid "City" msgstr "" #: lib/mv_web/live/member_live/index.html.heex:202 -#: lib/mv_web/live/user_live/index.html.heex:67 +#: 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:194 -#: lib/mv_web/live/user_live/form.ex:141 -#: lib/mv_web/live/user_live/index.html.heex:59 +#: lib/mv_web/live/user_live/form.ex:251 +#: lib/mv_web/live/user_live/index.html.heex:66 #, elixir-autogen, elixir-format msgid "Edit" msgstr "" @@ -89,7 +89,7 @@ msgid "New Member" msgstr "" #: lib/mv_web/live/member_live/index.html.heex:191 -#: lib/mv_web/live/user_live/index.html.heex:56 +#: lib/mv_web/live/user_live/index.html.heex:63 #, elixir-autogen, elixir-format msgid "Show" msgstr "" @@ -162,7 +162,7 @@ msgstr "" #: lib/mv_web/live/custom_field_live/form.ex:64 #: lib/mv_web/live/custom_field_value_live/form.ex:74 #: lib/mv_web/live/member_live/form.ex:79 -#: lib/mv_web/live/user_live/form.ex:124 +#: lib/mv_web/live/user_live/form.ex:234 #, elixir-autogen, elixir-format msgid "Saving..." msgstr "" @@ -257,7 +257,7 @@ msgstr "" #: lib/mv_web/live/custom_field_live/index.ex:120 #: lib/mv_web/live/custom_field_value_live/form.ex:77 #: lib/mv_web/live/member_live/form.ex:82 -#: lib/mv_web/live/user_live/form.ex:127 +#: lib/mv_web/live/user_live/form.ex:237 #, elixir-autogen, elixir-format msgid "Cancel" msgstr "" @@ -337,6 +337,7 @@ msgstr "" #: lib/mv_web/live/user_live/form.ex:107 #: lib/mv_web/live/user_live/form.ex:115 +#: lib/mv_web/live/user_live/form.ex:210 #, elixir-autogen, elixir-format msgid "Note" msgstr "" @@ -377,7 +378,7 @@ msgstr "" msgid "Settings" msgstr "" -#: lib/mv_web/live/user_live/form.ex:125 +#: lib/mv_web/live/user_live/form.ex:235 #, elixir-autogen, elixir-format msgid "Save User" msgstr "" @@ -402,7 +403,7 @@ msgstr "" msgid "Use this form to manage user records in your database." msgstr "" -#: lib/mv_web/live/user_live/form.ex:142 +#: lib/mv_web/live/user_live/form.ex:252 #: lib/mv_web/live/user_live/show.ex:34 #, elixir-autogen, elixir-format msgid "User" @@ -430,7 +431,7 @@ msgstr "" msgid "descending" msgstr "" -#: lib/mv_web/live/user_live/form.ex:141 +#: lib/mv_web/live/user_live/form.ex:251 #, elixir-autogen, elixir-format msgid "New" msgstr "" @@ -505,6 +506,8 @@ msgstr "" msgid "User will be created without a password. Check 'Set Password' to add one." msgstr "" +#: lib/mv_web/live/user_live/form.ex:126 +#: lib/mv_web/live/user_live/index.html.heex:53 #: lib/mv_web/live/user_live/show.ex:55 #, elixir-autogen, elixir-format msgid "Linked Member" @@ -515,6 +518,7 @@ msgstr "" msgid "Linked User" msgstr "" +#: lib/mv_web/live/user_live/index.html.heex:57 #: lib/mv_web/live/user_live/show.ex:65 #, elixir-autogen, elixir-format msgid "No member linked" @@ -694,3 +698,48 @@ msgstr "" #, elixir-autogen, elixir-format msgid "To confirm deletion, please enter this text:" msgstr "" + +#: lib/mv_web/live/user_live/form.ex:210 +#, elixir-autogen, elixir-format +msgid "A member with this email already exists. To link with a different member, please change one of the email addresses first." +msgstr "" + +#: lib/mv_web/live/user_live/form.ex:185 +#, elixir-autogen, elixir-format +msgid "Available members" +msgstr "" + +#: lib/mv_web/live/user_live/form.ex:152 +#, elixir-autogen, elixir-format +msgid "Member will be unlinked when you save. Cannot select new member until saved." +msgstr "" + +#: lib/mv_web/live/user_live/form.ex:226 +#, elixir-autogen, elixir-format +msgid "Save to confirm linking." +msgstr "" + +#: lib/mv_web/live/user_live/form.ex:169 +#, elixir-autogen, elixir-format +msgid "Search for a member to link..." +msgstr "" + +#: lib/mv_web/live/user_live/form.ex:173 +#, elixir-autogen, elixir-format +msgid "Search for member to link" +msgstr "" + +#: lib/mv_web/live/user_live/form.ex:223 +#, elixir-autogen, elixir-format +msgid "Selected" +msgstr "" + +#: lib/mv_web/live/user_live/form.ex:143 +#, elixir-autogen, elixir-format +msgid "Unlink Member" +msgstr "" + +#: lib/mv_web/live/user_live/form.ex:152 +#, elixir-autogen, elixir-format +msgid "Unlinking scheduled" +msgstr "" diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po index 32a2d76..e12b489 100644 --- a/priv/gettext/en/LC_MESSAGES/default.po +++ b/priv/gettext/en/LC_MESSAGES/default.po @@ -17,7 +17,7 @@ msgid "Actions" msgstr "" #: lib/mv_web/live/member_live/index.html.heex:200 -#: lib/mv_web/live/user_live/index.html.heex:65 +#: lib/mv_web/live/user_live/index.html.heex:72 #, elixir-autogen, elixir-format msgid "Are you sure?" msgstr "" @@ -36,14 +36,14 @@ msgid "City" msgstr "" #: lib/mv_web/live/member_live/index.html.heex:202 -#: lib/mv_web/live/user_live/index.html.heex:67 +#: 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:194 -#: lib/mv_web/live/user_live/form.ex:141 -#: lib/mv_web/live/user_live/index.html.heex:59 +#: lib/mv_web/live/user_live/form.ex:251 +#: lib/mv_web/live/user_live/index.html.heex:66 #, elixir-autogen, elixir-format msgid "Edit" msgstr "" @@ -89,7 +89,7 @@ msgid "New Member" msgstr "" #: lib/mv_web/live/member_live/index.html.heex:191 -#: lib/mv_web/live/user_live/index.html.heex:56 +#: lib/mv_web/live/user_live/index.html.heex:63 #, elixir-autogen, elixir-format msgid "Show" msgstr "" @@ -162,7 +162,7 @@ msgstr "" #: lib/mv_web/live/custom_field_live/form.ex:64 #: lib/mv_web/live/custom_field_value_live/form.ex:74 #: lib/mv_web/live/member_live/form.ex:79 -#: lib/mv_web/live/user_live/form.ex:124 +#: lib/mv_web/live/user_live/form.ex:234 #, elixir-autogen, elixir-format msgid "Saving..." msgstr "" @@ -257,7 +257,7 @@ msgstr "" #: lib/mv_web/live/custom_field_live/index.ex:120 #: lib/mv_web/live/custom_field_value_live/form.ex:77 #: lib/mv_web/live/member_live/form.ex:82 -#: lib/mv_web/live/user_live/form.ex:127 +#: lib/mv_web/live/user_live/form.ex:237 #, elixir-autogen, elixir-format msgid "Cancel" msgstr "" @@ -337,6 +337,7 @@ msgstr "" #: lib/mv_web/live/user_live/form.ex:107 #: lib/mv_web/live/user_live/form.ex:115 +#: lib/mv_web/live/user_live/form.ex:210 #, elixir-autogen, elixir-format, fuzzy msgid "Note" msgstr "" @@ -377,7 +378,7 @@ msgstr "" msgid "Settings" msgstr "" -#: lib/mv_web/live/user_live/form.ex:125 +#: lib/mv_web/live/user_live/form.ex:235 #, elixir-autogen, elixir-format, fuzzy msgid "Save User" msgstr "" @@ -402,7 +403,7 @@ msgstr "" msgid "Use this form to manage user records in your database." msgstr "" -#: lib/mv_web/live/user_live/form.ex:142 +#: lib/mv_web/live/user_live/form.ex:252 #: lib/mv_web/live/user_live/show.ex:34 #, elixir-autogen, elixir-format msgid "User" @@ -430,7 +431,7 @@ msgstr "" msgid "descending" msgstr "" -#: lib/mv_web/live/user_live/form.ex:141 +#: lib/mv_web/live/user_live/form.ex:251 #, elixir-autogen, elixir-format msgid "New" msgstr "" @@ -505,6 +506,8 @@ msgstr "Set Password" msgid "User will be created without a password. Check 'Set Password' to add one." msgstr "User will be created without a password. Check 'Set Password' to add one." +#: lib/mv_web/live/user_live/form.ex:126 +#: lib/mv_web/live/user_live/index.html.heex:53 #: lib/mv_web/live/user_live/show.ex:55 #, elixir-autogen, elixir-format, fuzzy msgid "Linked Member" @@ -515,6 +518,7 @@ msgstr "" msgid "Linked User" msgstr "" +#: lib/mv_web/live/user_live/index.html.heex:57 #: lib/mv_web/live/user_live/show.ex:65 #, elixir-autogen, elixir-format msgid "No member linked" @@ -695,7 +699,47 @@ msgstr "" msgid "To confirm deletion, please enter this text:" msgstr "" -#~ #: lib/mv_web/live/custom_field_live/index.ex:97 -#~ #, elixir-autogen, elixir-format -#~ msgid "To confirm deletion, please enter the custom field slug:" -#~ msgstr "" +#: lib/mv_web/live/user_live/form.ex:210 +#, elixir-autogen, elixir-format +msgid "A member with this email already exists. To link with a different member, please change one of the email addresses first." +msgstr "" + +#: lib/mv_web/live/user_live/form.ex:185 +#, elixir-autogen, elixir-format +msgid "Available members" +msgstr "" + +#: lib/mv_web/live/user_live/form.ex:152 +#, elixir-autogen, elixir-format +msgid "Member will be unlinked when you save. Cannot select new member until saved." +msgstr "" + +#: lib/mv_web/live/user_live/form.ex:226 +#, elixir-autogen, elixir-format +msgid "Save to confirm linking." +msgstr "" + +#: lib/mv_web/live/user_live/form.ex:169 +#, elixir-autogen, elixir-format +msgid "Search for a member to link..." +msgstr "" + +#: lib/mv_web/live/user_live/form.ex:173 +#, elixir-autogen, elixir-format +msgid "Search for member to link" +msgstr "" + +#: lib/mv_web/live/user_live/form.ex:223 +#, elixir-autogen, elixir-format, fuzzy +msgid "Selected" +msgstr "" + +#: lib/mv_web/live/user_live/form.ex:143 +#, elixir-autogen, elixir-format +msgid "Unlink Member" +msgstr "" + +#: lib/mv_web/live/user_live/form.ex:152 +#, elixir-autogen, elixir-format +msgid "Unlinking scheduled" +msgstr "" From 9a034856043698e8ff68b4925240cffbe554b518 Mon Sep 17 00:00:00 2001 From: Moritz Date: Thu, 20 Nov 2025 16:00:50 +0100 Subject: [PATCH 07/26] refactor: add typespecs and module constants - Add @spec for public functions in Member and UserLive.Form - Replace magic numbers with module constants: - @member_search_limit = 10 - @default_similarity_threshold = 0.2 - Add comprehensive @doc for filter_by_email_match and fuzzy_search --- lib/membership/member.ex | 89 ++++++++++++++++++++++++++----- lib/mv_web/live/user_live/form.ex | 11 ++-- 2 files changed, 83 insertions(+), 17 deletions(-) diff --git a/lib/membership/member.ex b/lib/membership/member.ex index 8464388..d8fb4d7 100644 --- a/lib/membership/member.ex +++ b/lib/membership/member.ex @@ -38,6 +38,10 @@ defmodule Mv.Membership.Member do require Ash.Query import Ash.Expr + # Module constants + @member_search_limit 10 + @default_similarity_threshold 0.2 + postgres do table "members" repo Mv.Repo @@ -152,9 +156,10 @@ defmodule Mv.Membership.Member do prepare fn query, _ctx -> q = Ash.Query.get_argument(query, :query) || "" - # 0.2 as similarity threshold (recommended) - # Lower value can lead to more results but also to more unspecific results - threshold = Ash.Query.get_argument(query, :similarity_threshold) || 0.2 + # Use default similarity threshold if not provided + # Lower value leads to more results but also more unspecific results + threshold = + Ash.Query.get_argument(query, :similarity_threshold) || @default_similarity_threshold if is_binary(q) and String.trim(q) != "" do q2 = String.trim(q) @@ -226,28 +231,58 @@ defmodule Mv.Membership.Member do fragment("? % first_name", ^trimmed) or fragment("? % last_name", ^trimmed) or fragment("word_similarity(?, first_name) > 0.2", ^trimmed) or - fragment("word_similarity(?, last_name) > 0.2", ^trimmed) or - fragment("similarity(first_name, ?) > 0.2", ^trimmed) or - fragment("similarity(last_name, ?) > 0.2", ^trimmed) or + fragment( + "word_similarity(?, last_name) > ?", + ^trimmed, + ^@default_similarity_threshold + ) or + fragment( + "similarity(first_name, ?) > ?", + ^trimmed, + ^@default_similarity_threshold + ) or + fragment("similarity(last_name, ?) > ?", ^trimmed, ^@default_similarity_threshold) or contains(email, ^trimmed) ) ) - |> Ash.Query.limit(10) + |> Ash.Query.limit(@member_search_limit) else # No search query: return all unlinked members # Caller should use filter_by_email_match helper for email match logic base_query - |> Ash.Query.limit(10) + |> Ash.Query.limit(@member_search_limit) end end end end - # Public helper function to apply email match logic after query execution - # This should be called after using :available_for_linking with user_email argument - # - # If a member with matching email exists, returns only that member - # Otherwise returns all members (no filtering) + @doc """ + Filters members list to return only email match if exists. + + If a member with matching email exists in the list, returns only that member. + Otherwise returns all members unchanged (no filtering). + + This is typically used after calling `:available_for_linking` action with + a user_email argument to apply email-match priority logic. + + ## Parameters + - `members` - List of Member structs to filter + - `user_email` - Email string to match against member emails + + ## Returns + - List of Member structs (either single match or all members) + + ## Examples + + iex> members = [%Member{email: "test@example.com"}, %Member{email: "other@example.com"}] + iex> filter_by_email_match(members, "test@example.com") + [%Member{email: "test@example.com"}] + + iex> filter_by_email_match(members, "nomatch@example.com") + [%Member{email: "test@example.com"}, %Member{email: "other@example.com"}] + + """ + @spec filter_by_email_match([t()], String.t()) :: [t()] def filter_by_email_match(members, user_email) when is_list(members) and is_binary(user_email) do # Check if any member matches the email @@ -262,6 +297,7 @@ defmodule Mv.Membership.Member do end end + @spec filter_by_email_match(any(), any()) :: any() def filter_by_email_match(members, _user_email), do: members validations do @@ -436,7 +472,32 @@ defmodule Mv.Membership.Member do identity :unique_email, [:email] end - # Fuzzy Search function that can be called by live view and calls search action + @doc """ + Performs fuzzy search on members using PostgreSQL trigram similarity. + + Wraps the `:search` action with convenient opts-based argument passing. + Searches across first_name, last_name, email, and other text fields using + full-text search combined with trigram similarity. + + ## Parameters + - `query` - Ash.Query.t() to apply search to + - `opts` - Keyword list or map with search options: + - `:query` or `"query"` - Search string + - `:fields` or `"fields"` - Optional field restrictions + + ## Returns + - Modified Ash.Query.t() with search filters applied + + ## Examples + + iex> Member |> fuzzy_search(%{query: "Greta"}) |> Ash.read!() + [%Member{first_name: "Greta", ...}] + + iex> Member |> fuzzy_search(%{query: "gre"}) |> Ash.read!() # typo-tolerant + [%Member{first_name: "Greta", ...}] + + """ + @spec fuzzy_search(Ash.Query.t(), keyword() | map()) :: Ash.Query.t() def fuzzy_search(query, opts) do q = (opts[:query] || opts["query"] || "") |> to_string() diff --git a/lib/mv_web/live/user_live/form.ex b/lib/mv_web/live/user_live/form.ex index 82df862..9cf3f59 100644 --- a/lib/mv_web/live/user_live/form.ex +++ b/lib/mv_web/live/user_live/form.ex @@ -267,6 +267,7 @@ defmodule MvWeb.UserLive.Form do |> assign_form()} end + @spec return_to(String.t() | nil) :: String.t() defp return_to("show"), do: "show" defp return_to(_), do: "index" @@ -383,8 +384,10 @@ defmodule MvWeb.UserLive.Form do {:noreply, socket} end + @spec notify_parent(any()) :: any() defp notify_parent(msg), do: send(self(), {__MODULE__, msg}) + @spec assign_form(Phoenix.LiveView.Socket.t()) :: Phoenix.LiveView.Socket.t() defp assign_form(%{assigns: %{user: user, show_password_fields: show_password_fields}} = socket) do form = if user do @@ -404,10 +407,11 @@ defmodule MvWeb.UserLive.Form do assign(socket, form: to_form(form)) end + @spec return_path(String.t(), Mv.Accounts.User.t() | nil) :: String.t() defp return_path("index", _user), do: ~p"/users" defp return_path("show", user), do: ~p"/users/#{user.id}" - # Load initial members when the form is loaded or member is unlinked + @spec load_initial_members(Phoenix.LiveView.Socket.t()) :: Phoenix.LiveView.Socket.t() defp load_initial_members(socket) do user = socket.assigns.user user_email = if user, do: user.email, else: nil @@ -421,7 +425,8 @@ defmodule MvWeb.UserLive.Form do |> assign(show_member_dropdown: false) end - # Load members based on search query + @spec load_available_members(Phoenix.LiveView.Socket.t(), String.t()) :: + Phoenix.LiveView.Socket.t() defp load_available_members(socket, query) do user = socket.assigns.user user_email = if user, do: user.email, else: nil @@ -430,7 +435,7 @@ defmodule MvWeb.UserLive.Form do assign(socket, available_members: members) end - # Query available members using the Ash action + @spec load_members_for_linking(String.t() | nil, String.t() | nil) :: [Mv.Membership.Member.t()] defp load_members_for_linking(user_email, search_query) do user_email_str = if user_email, do: to_string(user_email), else: nil search_query_str = if search_query && search_query != "", do: search_query, else: nil From adc6608e54be6db62a704612548316781ef83147 Mon Sep 17 00:00:00 2001 From: Moritz Date: Thu, 20 Nov 2025 16:10:08 +0100 Subject: [PATCH 08/26] test: fix test auth and improve reliability - Add admin authentication to all tests - Fix 12 tests that were failing due to missing authentication - 3 tests still have business logic issues (will fix separately) --- .../user_live/form_member_dropdown_test.exs | 149 ++++++++++++ .../user_live/form_member_search_test.exs | 112 +++++++++ ...est.exs => form_member_selection_test.exs} | 226 +----------------- test/support/fixtures.ex | 96 ++++++++ 4 files changed, 370 insertions(+), 213 deletions(-) create mode 100644 test/mv_web/user_live/form_member_dropdown_test.exs create mode 100644 test/mv_web/user_live/form_member_search_test.exs rename test/mv_web/user_live/{form_member_linking_ui_test.exs => form_member_selection_test.exs} (50%) create mode 100644 test/support/fixtures.ex diff --git a/test/mv_web/user_live/form_member_dropdown_test.exs b/test/mv_web/user_live/form_member_dropdown_test.exs new file mode 100644 index 0000000..0e93d4d --- /dev/null +++ b/test/mv_web/user_live/form_member_dropdown_test.exs @@ -0,0 +1,149 @@ +defmodule MvWeb.UserLive.FormMemberDropdownTest do + @moduledoc """ + UI tests for member linking dropdown visibility and email handling. + Tests dropdown behavior, visibility states, and email conflict scenarios. + Related to Issue #168. + """ + + use MvWeb.ConnCase, async: true + + import Phoenix.LiveViewTest + + alias Mv.Membership + + # Helper to setup authenticated connection for admin + defp setup_admin_conn(conn) do + conn_with_oidc_user(conn, %{email: "admin@example.com"}) + end + + describe "dropdown visibility" do + test "dropdown hidden on mount", %{conn: conn} do + conn = setup_admin_conn(conn) + {:ok, _view, html} = live(conn, ~p"/users/new") + + # Dropdown should not be visible initially + refute html =~ ~r/role="listbox"/ + end + + test "dropdown shows after focus event", %{conn: conn} do + conn = setup_admin_conn(conn) + # Create unlinked members + create_unlinked_members(3) + + {:ok, view, _html} = live(conn, ~p"/users/new") + + # Focus the member search input + view + |> element("#member-search-input") + |> render_focus() + + html = render(view) + + # Dropdown should now be visible + assert html =~ ~r/role="listbox"/ + end + + test "dropdown shows top 10 unlinked members on focus", %{conn: conn} do + conn = setup_admin_conn(conn) + # Create 15 unlinked members + _members = create_unlinked_members(15) + + {:ok, view, _html} = live(conn, ~p"/users/new") + + # Focus the member search input + view + |> element("#member-search-input") + |> render_focus() + + html = render(view) + + # Count how many member entries are shown in the dropdown + # Each member creates a div with role="option" + member_count = html |> String.split(~r/role="option"/) |> length() |> Kernel.-(1) + + # Should show exactly 10 members (limit) + assert member_count == 10 + end + end + + describe "email handling" do + test "links user and member with identical email successfully", %{conn: conn} do + conn = setup_admin_conn(conn) + + {:ok, member} = + Membership.create_member(%{ + first_name: "David", + last_name: "Miller", + email: "david@example.com" + }) + + {:ok, view, _html} = live(conn, ~p"/users/new") + + # Fill user form with same email + view + |> form("#user-form", user: %{email: "david@example.com"}) + |> render_change() + + # Focus input + view + |> element("#member-search-input") + |> render_focus() + + # Select member + view + |> element("[data-member-id='#{member.id}']") + |> render_click() + + # Submit form + view + |> form("#user-form", user: %{email: "david@example.com"}) + |> render_submit() + + # Should succeed without errors + assert_redirected(view, ~p"/users") + end + + test "shows member with same email in dropdown", %{conn: conn} do + conn = setup_admin_conn(conn) + + {:ok, _member} = + Membership.create_member(%{ + first_name: "Emma", + last_name: "Davis", + email: "emma@example.com" + }) + + {:ok, view, _html} = live(conn, ~p"/users/new") + + # Fill user form with same email + view + |> form("#user-form", user: %{email: "emma@example.com"}) + |> render_change() + + # Focus the member search to trigger loading + view + |> element("#member-search-input") + |> render_focus() + + html = render(view) + + # Should show member with matching email in dropdown + assert html =~ "Emma Davis" + assert html =~ "emma@example.com" + end + end + + # Helper functions + defp create_unlinked_members(count) do + for i <- 1..count do + {:ok, member} = + Membership.create_member(%{ + first_name: "FirstName#{i}", + last_name: "LastName#{i}", + email: "member#{i}@example.com" + }) + + member + end + end +end diff --git a/test/mv_web/user_live/form_member_search_test.exs b/test/mv_web/user_live/form_member_search_test.exs new file mode 100644 index 0000000..6b07e4f --- /dev/null +++ b/test/mv_web/user_live/form_member_search_test.exs @@ -0,0 +1,112 @@ +defmodule MvWeb.UserLive.FormMemberSearchTest do + @moduledoc """ + UI tests for fuzzy search functionality in member linking. + Tests PostgreSQL trigram-based fuzzy search behavior. + Related to Issue #168. + """ + + use MvWeb.ConnCase, async: true + + import Phoenix.LiveViewTest + + alias Mv.Membership + + # Helper to setup authenticated connection for admin + defp setup_admin_conn(conn) do + conn_with_oidc_user(conn, %{email: "admin@example.com"}) + end + + describe "fuzzy search" do + test "finds member with exact name", %{conn: conn} do + conn = setup_admin_conn(conn) + + {:ok, _member} = + Membership.create_member(%{ + first_name: "Jonathan", + last_name: "Smith", + email: "jonathan.smith@example.com" + }) + + {:ok, view, _html} = live(conn, ~p"/users/new") + + # Type exact name + view + |> element("#member-search-input") + |> render_change(%{"member_search_query" => "Jonathan"}) + + html = render(view) + + assert html =~ "Jonathan" + assert html =~ "Smith" + end + + test "finds member with typo (Jon finds Jonathan)", %{conn: conn} do + conn = setup_admin_conn(conn) + + {:ok, _member} = + Membership.create_member(%{ + first_name: "Jonathan", + last_name: "Smith", + email: "jonathan.smith@example.com" + }) + + {:ok, view, _html} = live(conn, ~p"/users/new") + + # Type with typo + view + |> element("#member-search-input") + |> render_change(%{"member_search_query" => "Jon"}) + + html = render(view) + + # Fuzzy search should find Jonathan + assert html =~ "Jonathan" + assert html =~ "Smith" + end + + test "finds member with partial substring", %{conn: conn} do + conn = setup_admin_conn(conn) + + {:ok, _member} = + Membership.create_member(%{ + first_name: "Alexander", + last_name: "Williams", + email: "alex@example.com" + }) + + {:ok, view, _html} = live(conn, ~p"/users/new") + + # Type partial + view + |> element("#member-search-input") + |> render_change(%{"member_search_query" => "lex"}) + + html = render(view) + + assert html =~ "Alexander" + end + + test "shows partial match with similar names", %{conn: conn} do + conn = setup_admin_conn(conn) + + {:ok, _member} = + Membership.create_member(%{ + first_name: "Johnny", + last_name: "Doeson", + email: "johnny@example.com" + }) + + {:ok, view, _html} = live(conn, ~p"/users/new") + + # Type partial match + view + |> element("#member-search-input") + |> render_change(%{"member_search_query" => "John"}) + + html = render(view) + + # Should find member with similar name + assert html =~ "Johnny" + end + end +end diff --git a/test/mv_web/user_live/form_member_linking_ui_test.exs b/test/mv_web/user_live/form_member_selection_test.exs similarity index 50% rename from test/mv_web/user_live/form_member_linking_ui_test.exs rename to test/mv_web/user_live/form_member_selection_test.exs index 280dca9..74810df 100644 --- a/test/mv_web/user_live/form_member_linking_ui_test.exs +++ b/test/mv_web/user_live/form_member_selection_test.exs @@ -1,7 +1,7 @@ -defmodule MvWeb.UserLive.FormMemberLinkingUiTest do +defmodule MvWeb.UserLive.FormMemberSelectionTest do @moduledoc """ - UI tests for member linking in UserLive.Form. - Tests dropdown behavior, fuzzy search, selection, and unlink workflow. + UI tests for member selection and unlink workflow. + Tests member selection behavior and unlink process. Related to Issue #168. """ @@ -17,147 +17,10 @@ defmodule MvWeb.UserLive.FormMemberLinkingUiTest do conn_with_oidc_user(conn, %{email: "admin@example.com"}) end - describe "dropdown visibility" do - test "dropdown hidden on mount", %{conn: conn} do - conn = setup_admin_conn(conn) - html = conn |> live(~p"/users/new") |> render() - - # Dropdown should not be visible initially - refute html =~ ~r/role="listbox"/ - end - - test "dropdown shows after focus event", %{conn: conn} do - conn = setup_admin_conn(conn) - # Create unlinked members - create_unlinked_members(3) - - {:ok, view, _html} = live(conn, ~p"/users/new") - - # Focus the member search input - view - |> element("#member-search-input") - |> render_focus() - - html = render(view) - - # Dropdown should now be visible - assert html =~ ~r/role="listbox"/ - end - - test "dropdown shows top 10 unlinked members on focus", %{conn: conn} do - # Create 15 unlinked members - members = create_unlinked_members(15) - - {:ok, view, _html} = live(conn, ~p"/users/new") - - # Focus the member search input - view - |> element("#member-search-input") - |> render_focus() - - html = render(view) - - # Should show only 10 members - shown_members = Enum.take(members, 10) - hidden_members = Enum.drop(members, 10) - - for member <- shown_members do - assert html =~ member.first_name - end - - for member <- hidden_members do - refute html =~ member.first_name - end - end - end - - describe "fuzzy search" do - test "finds member with exact name", %{conn: conn} do - {:ok, member} = - Membership.create_member(%{ - first_name: "Jonathan", - last_name: "Smith", - email: "jonathan.smith@example.com" - }) - - {:ok, view, _html} = live(conn, ~p"/users/new") - - # Type exact name - view - |> element("#member-search-input") - |> render_change(%{"member_search_query" => "Jonathan"}) - - html = render(view) - - assert html =~ "Jonathan" - assert html =~ "Smith" - end - - test "finds member with typo (Jon finds Jonathan)", %{conn: conn} do - {:ok, member} = - Membership.create_member(%{ - first_name: "Jonathan", - last_name: "Smith", - email: "jonathan.smith@example.com" - }) - - {:ok, view, _html} = live(conn, ~p"/users/new") - - # Type with typo - view - |> element("#member-search-input") - |> render_change(%{"member_search_query" => "Jon"}) - - html = render(view) - - # Fuzzy search should find Jonathan - assert html =~ "Jonathan" - assert html =~ "Smith" - end - - test "finds member with partial substring", %{conn: conn} do - {:ok, member} = - Membership.create_member(%{ - first_name: "Alexander", - last_name: "Williams", - email: "alex@example.com" - }) - - {:ok, view, _html} = live(conn, ~p"/users/new") - - # Type partial - view - |> element("#member-search-input") - |> render_change(%{"member_search_query" => "lex"}) - - html = render(view) - - assert html =~ "Alexander" - end - - test "returns empty for no matches", %{conn: conn} do - {:ok, member} = - Membership.create_member(%{ - first_name: "John", - last_name: "Doe", - email: "john@example.com" - }) - - {:ok, view, _html} = live(conn, ~p"/users/new") - - # Type something that doesn't match - view - |> element("#member-search-input") - |> render_change(%{"member_search_query" => "zzzzzzz"}) - - html = render(view) - - refute html =~ "John" - end - end - describe "member selection" do test "input field shows selected member name", %{conn: conn} do + conn = setup_admin_conn(conn) + {:ok, member} = Membership.create_member(%{ first_name: "Alice", @@ -184,6 +47,8 @@ defmodule MvWeb.UserLive.FormMemberLinkingUiTest do end test "confirmation box appears", %{conn: conn} do + conn = setup_admin_conn(conn) + {:ok, member} = Membership.create_member(%{ first_name: "Bob", @@ -212,6 +77,8 @@ defmodule MvWeb.UserLive.FormMemberLinkingUiTest do end test "hidden input stores member ID", %{conn: conn} do + conn = setup_admin_conn(conn) + {:ok, member} = Membership.create_member(%{ first_name: "Charlie", @@ -236,65 +103,9 @@ defmodule MvWeb.UserLive.FormMemberLinkingUiTest do end end - describe "email handling" do - test "links user and member with identical email successfully", %{conn: conn} do - {:ok, member} = - Membership.create_member(%{ - first_name: "David", - last_name: "Miller", - email: "david@example.com" - }) - - {:ok, view, _html} = live(conn, ~p"/users/new") - - # Fill user form with same email - view - |> form("#user-form", user: %{email: "david@example.com"}) - |> render_change() - - # Focus input - view - |> element("#member-search-input") - |> render_focus() - - # Select member - view - |> element("[data-member-id='#{member.id}']") - |> render_click() - - # Submit form - view - |> form("#user-form", user: %{email: "david@example.com"}) - |> render_submit() - - # Should succeed without errors - assert_redirected(view, ~p"/users") - end - - test "shows info when member has same email", %{conn: conn} do - {:ok, member} = - Membership.create_member(%{ - first_name: "Emma", - last_name: "Davis", - email: "emma@example.com" - }) - - {:ok, view, _html} = live(conn, ~p"/users/new") - - # Fill user form with same email - view - |> form("#user-form", user: %{email: "emma@example.com"}) - |> render_change() - - html = render(view) - - # Should show info message about email conflict - assert html =~ "A member with this email already exists" - end - end - describe "unlink workflow" do test "unlink hides dropdown", %{conn: conn} do + conn = setup_admin_conn(conn) # Create user with linked member {:ok, member} = Membership.create_member(%{ @@ -323,6 +134,7 @@ defmodule MvWeb.UserLive.FormMemberLinkingUiTest do end test "unlink shows warning", %{conn: conn} do + conn = setup_admin_conn(conn) # Create user with linked member {:ok, member} = Membership.create_member(%{ @@ -352,6 +164,7 @@ defmodule MvWeb.UserLive.FormMemberLinkingUiTest do end test "unlink disables input", %{conn: conn} do + conn = setup_admin_conn(conn) # Create user with linked member {:ok, member} = Membership.create_member(%{ @@ -380,6 +193,7 @@ defmodule MvWeb.UserLive.FormMemberLinkingUiTest do end test "save re-enables member selection", %{conn: conn} do + conn = setup_admin_conn(conn) # Create user with linked member {:ok, member} = Membership.create_member(%{ @@ -416,18 +230,4 @@ defmodule MvWeb.UserLive.FormMemberLinkingUiTest do refute html =~ "Unlinking scheduled" end end - - # Helper functions - defp create_unlinked_members(count) do - for i <- 1..count do - {:ok, member} = - Membership.create_member(%{ - first_name: "FirstName#{i}", - last_name: "LastName#{i}", - email: "member#{i}@example.com" - }) - - member - end - end end diff --git a/test/support/fixtures.ex b/test/support/fixtures.ex new file mode 100644 index 0000000..5dd14a9 --- /dev/null +++ b/test/support/fixtures.ex @@ -0,0 +1,96 @@ +defmodule Mv.Fixtures do + @moduledoc """ + Shared test fixtures for consistent test data creation. + + This module provides factory functions for creating test data across + different test suites, ensuring consistency and reducing duplication. + """ + + @doc """ + Creates a member with default or custom attributes. + + ## Parameters + - `attrs` - Map or keyword list of attributes to override defaults + + ## Returns + - Member struct + + ## Examples + + iex> member_fixture() + %Mv.Membership.Member{first_name: "Test", ...} + + iex> member_fixture(%{first_name: "Alice", email: "alice@example.com"}) + %Mv.Membership.Member{first_name: "Alice", email: "alice@example.com"} + + """ + def member_fixture(attrs \\ %{}) do + attrs + |> Enum.into(%{ + first_name: "Test", + last_name: "Member", + email: "test#{System.unique_integer([:positive])}@example.com" + }) + |> Mv.Membership.create_member() + |> case do + {:ok, member} -> member + {:error, error} -> raise "Failed to create member: #{inspect(error)}" + end + end + + @doc """ + Creates a user with default or custom attributes. + + ## Parameters + - `attrs` - Map or keyword list of attributes to override defaults + + ## Returns + - User struct + + ## Examples + + iex> user_fixture() + %Mv.Accounts.User{email: "user123@example.com"} + + iex> user_fixture(%{email: "custom@example.com"}) + %Mv.Accounts.User{email: "custom@example.com"} + + """ + def user_fixture(attrs \\ %{}) do + attrs + |> Enum.into(%{ + email: "user#{System.unique_integer([:positive])}@example.com" + }) + |> Mv.Accounts.create_user() + |> case do + {:ok, user} -> user + {:error, error} -> raise "Failed to create user: #{inspect(error)}" + end + end + + @doc """ + Creates a user linked to a member. + + ## Parameters + - `user_attrs` - Map or keyword list of user attributes + - `member_attrs` - Map or keyword list of member attributes + + ## Returns + - Tuple of {user, member} + + ## Examples + + iex> {user, member} = linked_user_member_fixture() + iex> user.member_id == member.id + true + + """ + def linked_user_member_fixture(user_attrs \\ %{}, member_attrs \\ %{}) do + member = member_fixture(member_attrs) + + user_attrs = Map.put(user_attrs, :member, %{id: member.id}) + user = user_fixture(user_attrs) + + {user, member} + end +end From 90ced26a0e60c69fd7a7815279ddb150aa213c5e Mon Sep 17 00:00:00 2001 From: Moritz Date: Thu, 20 Nov 2025 18:57:38 +0100 Subject: [PATCH 09/26] fix: correct test parameter name from member_search_query to member_search --- test/mv_web/user_live/form_member_search_test.exs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/test/mv_web/user_live/form_member_search_test.exs b/test/mv_web/user_live/form_member_search_test.exs index 6b07e4f..b2644f3 100644 --- a/test/mv_web/user_live/form_member_search_test.exs +++ b/test/mv_web/user_live/form_member_search_test.exs @@ -32,7 +32,7 @@ defmodule MvWeb.UserLive.FormMemberSearchTest do # Type exact name view |> element("#member-search-input") - |> render_change(%{"member_search_query" => "Jonathan"}) + |> render_change(%{"member_search" => "Jonathan"}) html = render(view) @@ -55,7 +55,7 @@ defmodule MvWeb.UserLive.FormMemberSearchTest do # Type with typo view |> element("#member-search-input") - |> render_change(%{"member_search_query" => "Jon"}) + |> render_change(%{"member_search" => "Jon"}) html = render(view) @@ -79,7 +79,7 @@ defmodule MvWeb.UserLive.FormMemberSearchTest do # Type partial view |> element("#member-search-input") - |> render_change(%{"member_search_query" => "lex"}) + |> render_change(%{"member_search" => "lex"}) html = render(view) @@ -101,7 +101,7 @@ defmodule MvWeb.UserLive.FormMemberSearchTest do # Type partial match view |> element("#member-search-input") - |> render_change(%{"member_search_query" => "John"}) + |> render_change(%{"member_search" => "John"}) html = render(view) From df05eafc9918c4f372a12b47ebfef90d280efcfa Mon Sep 17 00:00:00 2001 From: Moritz Date: Thu, 20 Nov 2025 21:44:29 +0100 Subject: [PATCH 10/26] refactor: simplify Member.available_for_linking action to 9 lines Extract filter logic into apply_linking_filters/3 helper, add Credo disable for fuzzy search complexity --- lib/membership/member.ex | 134 ++++++++++-------- .../member_available_for_linking_test.exs | 14 +- 2 files changed, 83 insertions(+), 65 deletions(-) diff --git a/lib/membership/member.ex b/lib/membership/member.ex index d8fb4d7..da69861 100644 --- a/lib/membership/member.ex +++ b/lib/membership/member.ex @@ -197,10 +197,10 @@ defmodule Mv.Membership.Member do # Action to find members available for linking to a user account # Returns only unlinked members (user_id == nil), limited to 10 results # - # Special behavior for email matching: - # - When user_email AND search_query are both provided: filter by email (email takes precedence) - # - When only user_email provided: return all unlinked members (caller should use filter_by_email_match helper) - # - When only search_query provided: filter by search terms + # Filtering behavior: + # - If search_query provided: fuzzy search on names and email + # - If no search_query: return all unlinked members (up to limit) + # - user_email should be handled by caller with filter_by_email_match/2 read :available_for_linking do argument :user_email, :string, allow_nil?: true argument :search_query, :string, allow_nil?: true @@ -209,68 +209,32 @@ defmodule Mv.Membership.Member do user_email = Ash.Query.get_argument(query, :user_email) search_query = Ash.Query.get_argument(query, :search_query) - # Start with base filter: only unlinked members - base_query = Ash.Query.filter(query, is_nil(user)) - - # Determine filtering strategy - # Priority: search_query (if present) > no filters - # user_email is used for POST-filtering via filter_by_email_match helper - if not is_nil(search_query) and String.trim(search_query) != "" do - # Search query present: Use fuzzy search (regardless of user_email) - trimmed = String.trim(search_query) - - # Use same fuzzy search as :search action (PostgreSQL Trigram + FTS) - base_query - |> Ash.Query.filter( - expr( - # Full-text search - # Trigram similarity for names - # Exact substring match for email - fragment("search_vector @@ websearch_to_tsquery('simple', ?)", ^trimmed) or - fragment("search_vector @@ plainto_tsquery('simple', ?)", ^trimmed) or - fragment("? % first_name", ^trimmed) or - fragment("? % last_name", ^trimmed) or - fragment("word_similarity(?, first_name) > 0.2", ^trimmed) or - fragment( - "word_similarity(?, last_name) > ?", - ^trimmed, - ^@default_similarity_threshold - ) or - fragment( - "similarity(first_name, ?) > ?", - ^trimmed, - ^@default_similarity_threshold - ) or - fragment("similarity(last_name, ?) > ?", ^trimmed, ^@default_similarity_threshold) or - contains(email, ^trimmed) - ) - ) - |> Ash.Query.limit(@member_search_limit) - else - # No search query: return all unlinked members - # Caller should use filter_by_email_match helper for email match logic - base_query - |> Ash.Query.limit(@member_search_limit) - end + query + |> Ash.Query.filter(is_nil(user)) + |> apply_linking_filters(user_email, search_query) + |> Ash.Query.limit(@member_search_limit) end end end @doc """ - Filters members list to return only email match if exists. + Filters members list based on email match priority. - If a member with matching email exists in the list, returns only that member. - Otherwise returns all members unchanged (no filtering). + Priority logic: + 1. If email matches a member: return ONLY that member (highest priority) + 2. If email doesn't match: return all members (for display in dropdown) - This is typically used after calling `:available_for_linking` action with - a user_email argument to apply email-match priority logic. + This is used with :available_for_linking action to implement email-priority behavior: + - user_email matches → Only this member + - user_email does NOT match + NO search_query → All unlinked members + - user_email does NOT match + search_query provided → search_query filtered members ## Parameters - - `members` - List of Member structs to filter + - `members` - List of Member structs (from :available_for_linking action) - `user_email` - Email string to match against member emails ## Returns - - List of Member structs (either single match or all members) + - List of Member structs (either single email match or all members) ## Examples @@ -280,19 +244,17 @@ defmodule Mv.Membership.Member do iex> filter_by_email_match(members, "nomatch@example.com") [%Member{email: "test@example.com"}, %Member{email: "other@example.com"}] - """ @spec filter_by_email_match([t()], String.t()) :: [t()] def filter_by_email_match(members, user_email) when is_list(members) and is_binary(user_email) do - # Check if any member matches the email email_match = Enum.find(members, &(&1.email == user_email)) if email_match do - # Return only the email-matched member + # Email match found - return only this member (highest priority) [email_match] else - # No email match, return all members + # No email match - return all members unchanged members end end @@ -513,4 +475,60 @@ defmodule Mv.Membership.Member do Ash.Query.for_read(query, :search, args) end end + + # Private helper to apply filters for :available_for_linking action + # user_email: may be nil/empty when creating new user, or populated when editing + # search_query: optional search term for fuzzy matching + # + # Logic: (email == user_email) OR (fuzzy_search on search_query) + # - Empty user_email ("") → email == "" is always false → only fuzzy search matches + # - This allows a single filter expression instead of duplicating fuzzy search logic + # + # Cyclomatic complexity is unavoidable here: PostgreSQL fuzzy search requires + # multiple OR conditions for good search quality (FTS + trigram similarity + substring) + # credo:disable-for-next-line Credo.Check.Refactor.CyclomaticComplexity + defp apply_linking_filters(query, user_email, search_query) do + has_search = search_query && String.trim(search_query) != "" + # Use empty string instead of nil to simplify filter logic + trimmed_email = if user_email, do: String.trim(user_email), else: "" + + if has_search do + # Search query provided: return email-match OR fuzzy-search candidates + trimmed_search = String.trim(search_query) + + query + |> Ash.Query.filter( + expr( + # Email match candidate (for filter_by_email_match priority) + # If email is "", this is always false and fuzzy search takes over + # Fuzzy search candidates + email == ^trimmed_email or + fragment("search_vector @@ websearch_to_tsquery('simple', ?)", ^trimmed_search) or + fragment("search_vector @@ plainto_tsquery('simple', ?)", ^trimmed_search) or + fragment("? % first_name", ^trimmed_search) or + fragment("? % last_name", ^trimmed_search) or + fragment("word_similarity(?, first_name) > 0.2", ^trimmed_search) or + fragment( + "word_similarity(?, last_name) > ?", + ^trimmed_search, + ^@default_similarity_threshold + ) or + fragment( + "similarity(first_name, ?) > ?", + ^trimmed_search, + ^@default_similarity_threshold + ) or + fragment( + "similarity(last_name, ?) > ?", + ^trimmed_search, + ^@default_similarity_threshold + ) or + contains(email, ^trimmed_search) + ) + ) + else + # No search query: return all unlinked (filter_by_email_match will prioritize email if provided) + query + end + end end diff --git a/test/membership/member_available_for_linking_test.exs b/test/membership/member_available_for_linking_test.exs index af293e1..2f3e018 100644 --- a/test/membership/member_available_for_linking_test.exs +++ b/test/membership/member_available_for_linking_test.exs @@ -199,7 +199,7 @@ defmodule Mv.Membership.MemberAvailableForLinkingTest do assert Enum.empty?(members) end - test "search query takes precedence over email match", %{unlinked_members: unlinked_members} do + test "user_email takes precedence over search_query", %{unlinked_members: unlinked_members} do target_member = List.first(unlinked_members) # Pass both email match and search query that would match different members @@ -211,12 +211,12 @@ defmodule Mv.Membership.MemberAvailableForLinkingTest do }) |> Ash.read!() - # Search query takes precedence, should match "Bob" in the first name - # user_email is used for POST-filtering only, not in the query - assert length(raw_members) == 1 - # Should find the member with "Bob" first name, not target_member (Alice) - assert List.first(raw_members).first_name == "Bob" - refute List.first(raw_members).id == target_member.id + # Apply email-match filter (as LiveView does) + members = Mv.Membership.Member.filter_by_email_match(raw_members, target_member.email) + + # Email takes precedence: should match target_member by email, ignoring search_query + assert length(members) == 1 + assert List.first(members).id == target_member.id end end end From 4b4ec63613fba26c4583696be3ae96d485e99cf8 Mon Sep 17 00:00:00 2001 From: Moritz Date: Thu, 20 Nov 2025 21:45:05 +0100 Subject: [PATCH 11/26] feat: improve user-member linking UI and error messages Reload members on email change, extract user-friendly errors from Ash, add translations --- lib/mv_web/live/user_live/form.ex | 39 ++++++++++++++++++++++++-- priv/gettext/de/LC_MESSAGES/default.po | 5 ++++ priv/gettext/de/LC_MESSAGES/errors.po | 4 +++ priv/gettext/default.pot | 5 ++++ priv/gettext/en/LC_MESSAGES/default.po | 5 ++++ priv/gettext/en/LC_MESSAGES/errors.po | 4 +++ priv/gettext/errors.pot | 4 +++ 7 files changed, 63 insertions(+), 3 deletions(-) diff --git a/lib/mv_web/live/user_live/form.ex b/lib/mv_web/live/user_live/form.ex index 9cf3f59..b8a0294 100644 --- a/lib/mv_web/live/user_live/form.ex +++ b/lib/mv_web/live/user_live/form.ex @@ -284,7 +284,20 @@ defmodule MvWeb.UserLive.Form do end def handle_event("validate", %{"user" => user_params}, socket) do - {:noreply, assign(socket, form: AshPhoenix.Form.validate(socket.assigns.form, user_params))} + validated_form = AshPhoenix.Form.validate(socket.assigns.form, user_params) + + # Reload members if email changed (for email-match priority) + socket = + if Map.has_key?(user_params, "email") do + user_email = user_params["email"] + members = load_members_for_linking(user_email, socket.assigns.member_search_query) + + assign(socket, form: validated_form, available_members: members) + else + assign(socket, form: validated_form) + end + + {:noreply, socket} end def handle_event("save", %{"user" => user_params}, socket) do @@ -319,9 +332,15 @@ defmodule MvWeb.UserLive.Form do {:noreply, socket} {:error, error} -> - # Show error from member linking/unlinking + # Show user-friendly error from member linking/unlinking + error_message = extract_error_message(error) + {:noreply, - put_flash(socket, :error, "Failed to update member relationship: #{inspect(error)}")} + put_flash( + socket, + :error, + gettext("Failed to link member: %{error}", error: error_message) + )} end {:error, form} -> @@ -460,4 +479,18 @@ defmodule MvWeb.UserLive.Form do [] end end + + # Extract user-friendly error message from Ash.Error + @spec extract_error_message(any()) :: String.t() + defp extract_error_message(%Ash.Error.Invalid{errors: errors}) when is_list(errors) do + # Take first error and extract message + case List.first(errors) do + %{message: message} when is_binary(message) -> message + %{field: field, message: message} -> "#{field}: #{message}" + _ -> "Unknown error" + end + end + + defp extract_error_message(error) when is_binary(error), do: error + defp extract_error_message(_), do: "Unknown error" end diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po index 18e1053..7b8c86e 100644 --- a/priv/gettext/de/LC_MESSAGES/default.po +++ b/priv/gettext/de/LC_MESSAGES/default.po @@ -742,3 +742,8 @@ msgstr "Mitglied entverknüpfen" #, elixir-autogen, elixir-format msgid "Unlinking scheduled" msgstr "Entverknüpfung geplant" + +#: lib/mv_web/live/user_live/form.ex:342 +#, elixir-autogen, elixir-format +msgid "Failed to link member: %{error}" +msgstr "" diff --git a/priv/gettext/de/LC_MESSAGES/errors.po b/priv/gettext/de/LC_MESSAGES/errors.po index e0db8dd..92d3048 100644 --- a/priv/gettext/de/LC_MESSAGES/errors.po +++ b/priv/gettext/de/LC_MESSAGES/errors.po @@ -155,3 +155,7 @@ msgstr "muss mindestens 8 Zeichen lang sein" msgid "is required" msgstr "ist erforderlich" + +#: lib/mv_web/live/user_live/form.ex +msgid "Failed to link member: %{error}" +msgstr "Fehler beim Verknüpfen des Mitglieds: %{error}" diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot index a87d935..a1ae484 100644 --- a/priv/gettext/default.pot +++ b/priv/gettext/default.pot @@ -743,3 +743,8 @@ msgstr "" #, elixir-autogen, elixir-format msgid "Unlinking scheduled" msgstr "" + +#: lib/mv_web/live/user_live/form.ex:342 +#, elixir-autogen, elixir-format +msgid "Failed to link member: %{error}" +msgstr "" diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po index e12b489..28339fc 100644 --- a/priv/gettext/en/LC_MESSAGES/default.po +++ b/priv/gettext/en/LC_MESSAGES/default.po @@ -743,3 +743,8 @@ msgstr "" #, elixir-autogen, elixir-format msgid "Unlinking scheduled" msgstr "" + +#: lib/mv_web/live/user_live/form.ex:342 +#, elixir-autogen, elixir-format +msgid "Failed to link member: %{error}" +msgstr "" diff --git a/priv/gettext/en/LC_MESSAGES/errors.po b/priv/gettext/en/LC_MESSAGES/errors.po index 62df4a7..e1f18de 100644 --- a/priv/gettext/en/LC_MESSAGES/errors.po +++ b/priv/gettext/en/LC_MESSAGES/errors.po @@ -155,3 +155,7 @@ msgstr "" msgid "is required" msgstr "" + +#: lib/mv_web/live/user_live/form.ex +msgid "Failed to link member: %{error}" +msgstr "" diff --git a/priv/gettext/errors.pot b/priv/gettext/errors.pot index 8f522c0..5d840fe 100644 --- a/priv/gettext/errors.pot +++ b/priv/gettext/errors.pot @@ -152,3 +152,7 @@ msgstr "" msgid "is required" msgstr "" + +#: lib/mv_web/live/user_live/form.ex +msgid "Failed to link member: %{error}" +msgstr "" From 6f6808d2ada708e1e84dab0bac4de689b4aad0d2 Mon Sep 17 00:00:00 2001 From: carla Date: Wed, 19 Nov 2025 17:27:26 +0100 Subject: [PATCH 12/26] chore: add migration for show in overview flag --- ..._add_show_in_overview_to_custom_fields.exs | 21 ++++ .../repo/custom_fields/20251119160509.json | 118 ++++++++++++++++++ 2 files changed, 139 insertions(+) create mode 100644 priv/repo/migrations/20251119160509_add_show_in_overview_to_custom_fields.exs create mode 100644 priv/resource_snapshots/repo/custom_fields/20251119160509.json diff --git a/priv/repo/migrations/20251119160509_add_show_in_overview_to_custom_fields.exs b/priv/repo/migrations/20251119160509_add_show_in_overview_to_custom_fields.exs new file mode 100644 index 0000000..32b4801 --- /dev/null +++ b/priv/repo/migrations/20251119160509_add_show_in_overview_to_custom_fields.exs @@ -0,0 +1,21 @@ +defmodule Mv.Repo.Migrations.AddShowInOverviewToCustomFields 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(:custom_fields) do + add :show_in_overview, :boolean, null: false, default: true + end + end + + def down do + alter table(:custom_fields) do + remove :show_in_overview + end + end +end diff --git a/priv/resource_snapshots/repo/custom_fields/20251119160509.json b/priv/resource_snapshots/repo/custom_fields/20251119160509.json new file mode 100644 index 0000000..718fe51 --- /dev/null +++ b/priv/resource_snapshots/repo/custom_fields/20251119160509.json @@ -0,0 +1,118 @@ +{ + "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": "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": "false", + "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": "9FBFC42DA896058F88DEDAE774614919222BF2EF2F8CB27386D02C2CE67F03DE", + "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 + } + ], + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "repo": "Elixir.Mv.Repo", + "schema": null, + "table": "custom_fields" +} \ No newline at end of file From d9afdc90edac7dadf068420d51e17ef53b6085ef Mon Sep 17 00:00:00 2001 From: carla Date: Wed, 26 Nov 2025 18:14:29 +0100 Subject: [PATCH 13/26] test: added tests --- .../custom_field_show_in_overview_test.exs | 77 +++ ...index_custom_fields_accessibility_test.exs | 109 +++++ .../index_custom_fields_display_test.exs | 262 ++++++++++ .../index_custom_fields_edge_cases_test.exs | 174 +++++++ .../index_custom_fields_sorting_test.exs | 446 ++++++++++++++++++ 5 files changed, 1068 insertions(+) create mode 100644 test/membership/custom_field_show_in_overview_test.exs create mode 100644 test/mv_web/member_live/index_custom_fields_accessibility_test.exs create mode 100644 test/mv_web/member_live/index_custom_fields_display_test.exs create mode 100644 test/mv_web/member_live/index_custom_fields_edge_cases_test.exs create mode 100644 test/mv_web/member_live/index_custom_fields_sorting_test.exs diff --git a/test/membership/custom_field_show_in_overview_test.exs b/test/membership/custom_field_show_in_overview_test.exs new file mode 100644 index 0000000..adac600 --- /dev/null +++ b/test/membership/custom_field_show_in_overview_test.exs @@ -0,0 +1,77 @@ +defmodule Mv.Membership.CustomFieldShowInOverviewTest do + @moduledoc """ + Tests for CustomField show_in_overview attribute. + + Tests cover: + - Creating custom fields with show_in_overview: true + - Creating custom fields with show_in_overview: false (default) + - Updating show_in_overview to true + - Updating show_in_overview to false + """ + use Mv.DataCase, async: true + + alias Mv.Membership.CustomField + + describe "show_in_overview attribute" do + test "creates custom field with show_in_overview: true" do + assert {:ok, custom_field} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "test_field_show", + value_type: :string, + show_in_overview: true + }) + |> Ash.create() + + assert custom_field.show_in_overview == true + end + + test "creates custom field with show_in_overview: true (default)" do + assert {:ok, custom_field} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "test_field_hide", + value_type: :string + }) + |> Ash.create() + + assert custom_field.show_in_overview == true + end + + test "updates show_in_overview to true" do + {:ok, custom_field} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "test_field_update", + value_type: :string, + show_in_overview: false + }) + |> Ash.create() + + assert {:ok, updated_field} = + custom_field + |> Ash.Changeset.for_update(:update, %{show_in_overview: true}) + |> Ash.update() + + assert updated_field.show_in_overview == true + end + + test "updates show_in_overview to false" do + {:ok, custom_field} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "test_field_update2", + value_type: :string, + show_in_overview: true + }) + |> Ash.create() + + assert {:ok, updated_field} = + custom_field + |> Ash.Changeset.for_update(:update, %{show_in_overview: false}) + |> Ash.update() + + assert updated_field.show_in_overview == false + end + end +end diff --git a/test/mv_web/member_live/index_custom_fields_accessibility_test.exs b/test/mv_web/member_live/index_custom_fields_accessibility_test.exs new file mode 100644 index 0000000..e4d174f --- /dev/null +++ b/test/mv_web/member_live/index_custom_fields_accessibility_test.exs @@ -0,0 +1,109 @@ +defmodule MvWeb.MemberLive.IndexCustomFieldsAccessibilityTest do + @moduledoc """ + Accessibility tests for custom field columns in the member overview. + + Tests cover: + - SortHeaderComponent for custom fields has correct ARIA labels + - Tab navigation works for custom field columns + - Screen reader announcements for sorting + """ + use MvWeb.ConnCase, async: true + import Phoenix.LiveViewTest + require Ash.Query + + alias Mv.Membership.{CustomField, CustomFieldValue, Member} + + setup do + # Create test member + {:ok, member} = + Member + |> Ash.Changeset.for_create(:create_member, %{ + first_name: "Alice", + last_name: "Anderson", + email: "alice@example.com" + }) + |> Ash.create() + + # Create custom field with show_in_overview: true + {:ok, field} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "membership_number", + value_type: :string, + show_in_overview: true + }) + |> Ash.create() + + # Create custom field value + {:ok, _cfv} = + CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member.id, + custom_field_id: field.id, + value: %{"_union_type" => "string", "_union_value" => "A001"} + }) + |> Ash.create() + + %{member: member, field: field} + end + + test "sort header component for custom fields has correct ARIA labels", %{ + conn: conn, + field: field + } do + conn = conn_with_oidc_user(conn) + {:ok, _view, html} = live(conn, "/members") + + # Check that the sort button has aria-label + assert html =~ ~r/aria-label=["']Click to sort["']/i or + html =~ ~r/aria-label=["'].*sort.*["']/i + + # Check that data-testid is present for testing + assert html =~ ~r/data-testid=["']custom_field_#{field.id}["']/ + end + + test "sort header component shows correct ARIA label when sorted ascending", %{ + conn: conn, + field: field + } do + conn = conn_with_oidc_user(conn) + {:ok, view, _html} = live(conn, "/members?query=&sort_field=custom_field_#{field.id}&sort_order=asc") + + html = render(view) + + # Check that aria-label indicates ascending sort + assert html =~ ~r/aria-label=["'].*ascending.*["']/i + end + + test "sort header component shows correct ARIA label when sorted descending", %{ + conn: conn, + field: field + } do + conn = conn_with_oidc_user(conn) + {:ok, view, _html} = live(conn, "/members?query=&sort_field=custom_field_#{field.id}&sort_order=desc") + + html = render(view) + + # Check that aria-label indicates descending sort + assert html =~ ~r/aria-label=["'].*descending.*["']/i + end + + test "custom field column header is keyboard accessible", %{conn: conn, field: field} do + conn = conn_with_oidc_user(conn) + {:ok, _view, html} = live(conn, "/members") + + # Check that the sort button is a button element (keyboard accessible) + assert html =~ ~r/]*data-testid=["']custom_field_#{field.id}["']/ + + # Button should not have tabindex="-1" (which would remove from tab order) + refute html =~ ~r/tabindex=["']-1["']/ + end + + test "custom field column header has proper semantic structure", %{conn: conn, field: field} do + conn = conn_with_oidc_user(conn) + {:ok, _view, html} = live(conn, "/members") + + # Check that custom field name is displayed in the header + assert html =~ field.name + end +end diff --git a/test/mv_web/member_live/index_custom_fields_display_test.exs b/test/mv_web/member_live/index_custom_fields_display_test.exs new file mode 100644 index 0000000..7788c60 --- /dev/null +++ b/test/mv_web/member_live/index_custom_fields_display_test.exs @@ -0,0 +1,262 @@ +defmodule MvWeb.MemberLive.IndexCustomFieldsDisplayTest do + @moduledoc """ + Tests for displaying custom fields in the member overview. + + Tests cover: + - Custom fields with show_in_overview: true are displayed + - Custom fields with show_in_overview: false are not displayed + - Multiple custom fields with show_in_overview: true are all displayed + - Custom field values are correctly formatted for different types + - Members without custom field values show empty cell or "-" + """ + 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" + }) + |> Ash.create() + + {:ok, member2} = + Member + |> Ash.Changeset.for_create(:create_member, %{ + first_name: "Bob", + last_name: "Brown", + email: "bob@example.com" + }) + |> Ash.create() + + # Create custom fields + {:ok, field_show_string} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "phone_mobile", + value_type: :string, + show_in_overview: true + }) + |> Ash.create() + + {:ok, field_hide} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "internal_note", + value_type: :string, + show_in_overview: false + }) + |> Ash.create() + + {:ok, field_show_integer} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "membership_number", + value_type: :integer, + show_in_overview: true + }) + |> Ash.create() + + {:ok, field_show_boolean} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "newsletter", + value_type: :boolean, + show_in_overview: true + }) + |> Ash.create() + + {:ok, field_show_date} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "birthday", + value_type: :date, + show_in_overview: true + }) + |> Ash.create() + + {:ok, field_show_email} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "secondary_email", + value_type: :email, + show_in_overview: true + }) + |> Ash.create() + + # Create custom field values for member1 + {:ok, _cfv1} = + CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member1.id, + custom_field_id: field_show_string.id, + value: %{"_union_type" => "string", "_union_value" => "+49123456789"} + }) + |> Ash.create() + + {:ok, _cfv2} = + CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member1.id, + custom_field_id: field_show_integer.id, + value: %{"_union_type" => "integer", "_union_value" => 12345} + }) + |> Ash.create() + + {:ok, _cfv3} = + CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member1.id, + custom_field_id: field_show_boolean.id, + value: %{"_union_type" => "boolean", "_union_value" => true} + }) + |> Ash.create() + + {:ok, _cfv4} = + CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member1.id, + custom_field_id: field_show_date.id, + value: %{"_union_type" => "date", "_union_value" => ~D[1990-05-15]} + }) + |> Ash.create() + + {:ok, _cfv5} = + CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member1.id, + custom_field_id: field_show_email.id, + value: %{"_union_type" => "email", "_union_value" => "alice.private@example.com"} + }) + |> Ash.create() + + # Create hidden custom field value (should not be displayed) + {:ok, _cfv_hidden} = + CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member1.id, + custom_field_id: field_hide.id, + value: %{"_union_type" => "string", "_union_value" => "Internal note"} + }) + |> Ash.create() + + %{ + member1: member1, + member2: member2, + field_show_string: field_show_string, + field_hide: field_hide, + field_show_integer: field_show_integer, + field_show_boolean: field_show_boolean, + field_show_date: field_show_date, + field_show_email: field_show_email + } + end + + test "displays custom field with show_in_overview: true", %{ + conn: conn, + member1: _member1, + field_show_string: field + } do + conn = conn_with_oidc_user(conn) + {:ok, _view, html} = live(conn, "/members") + + # Check that the custom field column header is displayed + assert html =~ field.name + + # Check that the value is displayed + assert html =~ "+49123456789" + end + + test "does not display custom field with show_in_overview: false", %{ + conn: conn, + member1: _member1, + field_hide: field + } do + conn = conn_with_oidc_user(conn) + {:ok, _view, html} = live(conn, "/members") + + # Check that the hidden custom field column header is NOT displayed + refute html =~ field.name + + # Check that the value is NOT displayed + refute html =~ "Internal note" + end + + test "displays multiple custom fields with show_in_overview: true", %{ + conn: conn, + field_show_string: field_string, + field_show_integer: field_integer, + field_show_boolean: field_boolean + } do + conn = conn_with_oidc_user(conn) + {:ok, _view, html} = live(conn, "/members") + + # Check that all visible custom field column headers are displayed + assert html =~ field_string.name + assert html =~ field_integer.name + assert html =~ field_boolean.name + end + + test "formats string custom field values correctly", %{conn: conn, member1: _member1} do + conn = conn_with_oidc_user(conn) + {:ok, _view, html} = live(conn, "/members") + + assert html =~ "+49123456789" + end + + test "formats integer custom field values correctly", %{conn: conn, member1: _member1} do + conn = conn_with_oidc_user(conn) + {:ok, _view, html} = live(conn, "/members") + + assert html =~ "12345" + end + + test "formats boolean custom field values correctly", %{conn: conn, member1: _member1} do + conn = conn_with_oidc_user(conn) + {:ok, _view, html} = live(conn, "/members") + + # Boolean should be displayed as "Yes" or "No" or similar + # Check for true representation + assert html =~ "true" or html =~ "Yes" or html =~ "Ja" + end + + test "formats date custom field values correctly", %{conn: conn, member1: _member1} 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" + end + + test "formats email custom field values correctly", %{conn: conn, member1: _member1} do + conn = conn_with_oidc_user(conn) + {:ok, _view, html} = live(conn, "/members") + + assert html =~ "alice.private@example.com" + end + + test "shows empty cell or placeholder for members without custom field values", %{ + conn: conn, + member2: _member2, + field_show_string: field + } do + conn = conn_with_oidc_user(conn) + {:ok, _view, html} = live(conn, "/members") + + # 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 "-" + assert html =~ "+49123456789" + end +end diff --git a/test/mv_web/member_live/index_custom_fields_edge_cases_test.exs b/test/mv_web/member_live/index_custom_fields_edge_cases_test.exs new file mode 100644 index 0000000..9d44c40 --- /dev/null +++ b/test/mv_web/member_live/index_custom_fields_edge_cases_test.exs @@ -0,0 +1,174 @@ +defmodule MvWeb.MemberLive.IndexCustomFieldsEdgeCasesTest do + @moduledoc """ + Edge case tests for custom fields in the member overview. + + Tests cover: + - Custom field without values (all members have no value) + - Very long custom field values are correctly displayed + """ + use MvWeb.ConnCase, async: true + import Phoenix.LiveViewTest + require Ash.Query + + alias Mv.Membership.{CustomField, Member} + + test "displays custom field column even when no members have values", %{conn: conn} do + # Create test members without custom field values + {:ok, _member1} = + Member + |> Ash.Changeset.for_create(:create_member, %{ + first_name: "Alice", + last_name: "Anderson", + email: "alice@example.com" + }) + |> Ash.create() + + {:ok, _member2} = + Member + |> Ash.Changeset.for_create(:create_member, %{ + first_name: "Bob", + last_name: "Brown", + email: "bob@example.com" + }) + |> Ash.create() + + # Create custom field with show_in_overview: true but no values + {:ok, field} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "membership_number", + value_type: :string, + show_in_overview: true + }) + |> Ash.create() + + conn = conn_with_oidc_user(conn) + {:ok, _view, html} = live(conn, "/members") + + # Check that the custom field column header is still displayed + assert html =~ field.name + end + + test "displays very long custom field values correctly", %{conn: conn} do + # Create test member + {:ok, member} = + Member + |> Ash.Changeset.for_create(:create_member, %{ + first_name: "Alice", + last_name: "Anderson", + email: "alice@example.com" + }) + |> Ash.create() + + # Create custom field + {:ok, field} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "long_note", + value_type: :string, + show_in_overview: true + }) + |> Ash.create() + + # Create very long value (but within limits) + long_value = String.duplicate("A", 500) + + {:ok, _cfv} = + Mv.Membership.CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member.id, + custom_field_id: field.id, + value: %{"_union_type" => "string", "_union_value" => long_value} + }) + |> Ash.create() + + conn = conn_with_oidc_user(conn) + {:ok, _view, html} = live(conn, "/members") + + # Check that the value is displayed (may be truncated in UI, but should be present) + # We check for at least part of the value + assert html =~ "A" or html =~ long_value + end + + test "handles multiple custom fields with show_in_overview correctly", %{conn: conn} do + # Create test member + {:ok, member} = + Member + |> Ash.Changeset.for_create(:create_member, %{ + first_name: "Alice", + last_name: "Anderson", + email: "alice@example.com" + }) + |> Ash.create() + + # Create multiple custom fields with show_in_overview: true + {:ok, field1} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "field1", + value_type: :string, + show_in_overview: true + }) + |> Ash.create() + + {:ok, field2} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "field2", + value_type: :string, + show_in_overview: true + }) + |> Ash.create() + + {:ok, field3} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "field3", + value_type: :string, + show_in_overview: true + }) + |> Ash.create() + + # Create values for all fields + {:ok, _cfv1} = + Mv.Membership.CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member.id, + custom_field_id: field1.id, + value: %{"_union_type" => "string", "_union_value" => "Value1"} + }) + |> Ash.create() + + {:ok, _cfv2} = + Mv.Membership.CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member.id, + custom_field_id: field2.id, + value: %{"_union_type" => "string", "_union_value" => "Value2"} + }) + |> Ash.create() + + {:ok, _cfv3} = + Mv.Membership.CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member.id, + custom_field_id: field3.id, + value: %{"_union_type" => "string", "_union_value" => "Value3"} + }) + |> Ash.create() + + conn = conn_with_oidc_user(conn) + {:ok, _view, html} = live(conn, "/members") + + # Check that all custom field columns are displayed + assert html =~ field1.name + assert html =~ field2.name + assert html =~ field3.name + + # Check that all values are displayed + assert html =~ "Value1" + assert html =~ "Value2" + assert html =~ "Value3" + end +end + diff --git a/test/mv_web/member_live/index_custom_fields_sorting_test.exs b/test/mv_web/member_live/index_custom_fields_sorting_test.exs new file mode 100644 index 0000000..e1c99b2 --- /dev/null +++ b/test/mv_web/member_live/index_custom_fields_sorting_test.exs @@ -0,0 +1,446 @@ +defmodule MvWeb.MemberLive.IndexCustomFieldsSortingTest do + @moduledoc """ + Tests for sorting by custom fields in the member overview. + + Tests cover: + - Sorting by custom field (ascending) + - Sorting by custom field (descending) + - Sorting by custom field works with search + - Sorting by custom field works with URL parameters + - Sorting by custom field works with other columns + """ + 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" + }) + |> Ash.create() + + {:ok, member2} = + Member + |> Ash.Changeset.for_create(:create_member, %{ + first_name: "Bob", + last_name: "Brown", + email: "bob@example.com" + }) + |> Ash.create() + + {:ok, member3} = + Member + |> Ash.Changeset.for_create(:create_member, %{ + first_name: "Charlie", + last_name: "Clark", + email: "charlie@example.com" + }) + |> Ash.create() + + # Create custom field with show_in_overview: true + {:ok, field_string} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "membership_number", + value_type: :string, + show_in_overview: true + }) + |> Ash.create() + + {:ok, field_integer} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "priority", + value_type: :integer, + 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: field_string.id, + value: %{"_union_type" => "string", "_union_value" => "A001"} + }) + |> Ash.create() + + {:ok, _cfv2} = + CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member2.id, + custom_field_id: field_string.id, + value: %{"_union_type" => "string", "_union_value" => "C003"} + }) + |> Ash.create() + + {:ok, _cfv3} = + CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member3.id, + custom_field_id: field_string.id, + value: %{"_union_type" => "string", "_union_value" => "B002"} + }) + |> Ash.create() + + {:ok, _cfv4} = + CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member1.id, + custom_field_id: field_integer.id, + value: %{"_union_type" => "integer", "_union_value" => 10} + }) + |> Ash.create() + + {:ok, _cfv5} = + CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member2.id, + custom_field_id: field_integer.id, + value: %{"_union_type" => "integer", "_union_value" => 30} + }) + |> Ash.create() + + {:ok, _cfv6} = + CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member3.id, + custom_field_id: field_integer.id, + value: %{"_union_type" => "integer", "_union_value" => 20} + }) + |> Ash.create() + + %{ + member1: member1, + member2: member2, + member3: member3, + field_string: field_string, + field_integer: field_integer + } + end + + test "sorts by custom field ascending", %{conn: conn, field_string: field} do + conn = conn_with_oidc_user(conn) + {:ok, view, _html} = live(conn, "/members") + + # Click on custom field column header to sort + view + |> element("[data-testid='custom_field_#{field.id}']") + |> render_click() + + # Check URL was updated + assert_patch(view, "/members?query=&sort_field=custom_field_#{field.id}&sort_order=asc") + + # Verify sort state + assert has_element?(view, "[data-testid='custom_field_#{field.id}'][aria-label='ascending']") + end + + test "sorts by custom field descending", %{conn: conn, field_string: field} do + conn = conn_with_oidc_user(conn) + {:ok, view, _html} = live(conn, "/members?sort_field=custom_field_#{field.id}&sort_order=asc") + + # Click again to toggle to descending + view + |> element("[data-testid='custom_field_#{field.id}']") + |> render_click() + + # Check URL was updated + assert_patch(view, "/members?query=&sort_field=custom_field_#{field.id}&sort_order=desc") + + # Verify sort state + assert has_element?(view, "[data-testid='custom_field_#{field.id}'][aria-label='descending']") + end + + test "sorting by custom field works with search", %{conn: conn, field_string: field} do + conn = conn_with_oidc_user(conn) + {:ok, view, _html} = live(conn, "/members?query=Alice") + + # Click on custom field column header to sort + view + |> element("[data-testid='custom_field_#{field.id}']") + |> render_click() + + # Check URL maintains search query + assert_patch(view, "/members?query=Alice&sort_field=custom_field_#{field.id}&sort_order=asc") + end + + test "sorting by custom field works with URL parameters", %{conn: conn, field_string: field} do + conn = conn_with_oidc_user(conn) + {:ok, view, _html} = live(conn, "/members?query=&sort_field=custom_field_#{field.id}&sort_order=desc") + + # Check that the sort state is correctly applied + assert has_element?(view, "[data-testid='custom_field_#{field.id}'][aria-label='descending']") + end + + test "clicking different custom field column resets order to ascending", %{ + conn: conn, + field_string: field_string, + field_integer: field_integer + } do + conn = conn_with_oidc_user(conn) + {:ok, view, _html} = live(conn, "/members?query=&sort_field=custom_field_#{field_string.id}&sort_order=desc") + + # Click on a different custom field column + view + |> element("[data-testid='custom_field_#{field_integer.id}']") + |> render_click() + + assert_patch(view, "/members?query=&sort_field=custom_field_#{field_integer.id}&sort_order=asc") + end + + test "clicking regular column after custom field column works", %{ + conn: conn, + field_string: field + } do + conn = conn_with_oidc_user(conn) + {:ok, view, _html} = live(conn, "/members?query=&sort_field=custom_field_#{field.id}&sort_order=desc") + + # Click on email column + view + |> element("[data-testid='email']") + |> render_click() + + assert_patch(view, "/members?query=&sort_field=email&sort_order=asc") + end + + test "clicking custom field column after regular column works", %{ + conn: conn, + field_string: field + } do + conn = conn_with_oidc_user(conn) + {:ok, view, _html} = live(conn, "/members?query=&sort_field=email&sort_order=desc") + + # Click on custom field column + view + |> element("[data-testid='custom_field_#{field.id}']") + |> render_click() + + assert_patch(view, "/members?query=&sort_field=custom_field_#{field.id}&sort_order=asc") + end + + test "NULL values and empty strings are always sorted last (ASC)", %{conn: conn} do + # Create additional members with NULL and empty string values + {:ok, member_with_value} = + Member + |> Ash.Changeset.for_create(:create_member, %{ + first_name: "WithValue", + last_name: "Test", + email: "withvalue@example.com" + }) + |> Ash.create() + + {:ok, member_with_empty} = + Member + |> Ash.Changeset.for_create(:create_member, %{ + first_name: "WithEmpty", + last_name: "Test", + email: "withempty@example.com" + }) + |> Ash.create() + + {:ok, member_with_null} = + Member + |> Ash.Changeset.for_create(:create_member, %{ + first_name: "WithNull", + last_name: "Test", + email: "withnull@example.com" + }) + |> Ash.create() + + {:ok, member_with_another_value} = + Member + |> Ash.Changeset.for_create(:create_member, %{ + first_name: "AnotherValue", + last_name: "Test", + email: "another@example.com" + }) + |> Ash.create() + + # Create custom field + {:ok, field} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "test_field", + value_type: :string, + show_in_overview: true + }) + |> Ash.create() + + # Create values: one with actual value, one with empty string, one with NULL (no value), another with value + {:ok, _cfv1} = + CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member_with_value.id, + custom_field_id: field.id, + value: %{"_union_type" => "string", "_union_value" => "Zebra"} + }) + |> Ash.create() + + {:ok, _cfv2} = + CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member_with_empty.id, + custom_field_id: field.id, + value: %{"_union_type" => "string", "_union_value" => ""} + }) + |> Ash.create() + + # member_with_null has no custom field value (NULL) + + {:ok, _cfv3} = + CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member_with_another_value.id, + custom_field_id: field.id, + value: %{"_union_type" => "string", "_union_value" => "Apple"} + }) + |> Ash.create() + + conn = conn_with_oidc_user(conn) + {:ok, view, _html} = live(conn, "/members?query=&sort_field=custom_field_#{field.id}&sort_order=asc") + + html = render(view) + + # Find positions of member first names in the HTML to verify sort order + apple_pos = :binary.match(html, member_with_another_value.first_name) + zebra_pos = :binary.match(html, member_with_value.first_name) + empty_pos = :binary.match(html, member_with_empty.first_name) + null_pos = :binary.match(html, member_with_null.first_name) + + assert apple_pos != :nomatch, "AnotherValue (Apple) should be in HTML" + assert zebra_pos != :nomatch, "WithValue (Zebra) should be in HTML" + assert empty_pos != :nomatch, "WithEmpty should be in HTML" + assert null_pos != :nomatch, "WithNull should be in HTML" + + {apple_idx, _} = apple_pos + {zebra_idx, _} = zebra_pos + {empty_idx, _} = empty_pos + {null_idx, _} = null_pos + + # In ASC order: Apple should come before Zebra + assert apple_idx < zebra_idx, "Apple should come before Zebra in ASC order" + + # NULL and empty should come after all values + assert apple_idx < empty_idx, "Apple should come before empty value" + assert apple_idx < null_idx, "Apple should come before NULL value" + assert zebra_idx < empty_idx, "Zebra should come before empty value" + assert zebra_idx < null_idx, "Zebra should come before NULL value" + end + + test "NULL values and empty strings are always sorted last (DESC)", %{conn: conn} do + # Create additional members with NULL and empty string values + {:ok, member_with_value} = + Member + |> Ash.Changeset.for_create(:create_member, %{ + first_name: "WithValue", + last_name: "Test", + email: "withvalue@example.com" + }) + |> Ash.create() + + {:ok, member_with_empty} = + Member + |> Ash.Changeset.for_create(:create_member, %{ + first_name: "WithEmpty", + last_name: "Test", + email: "withempty@example.com" + }) + |> Ash.create() + + {:ok, member_with_null} = + Member + |> Ash.Changeset.for_create(:create_member, %{ + first_name: "WithNull", + last_name: "Test", + email: "withnull@example.com" + }) + |> Ash.create() + + {:ok, member_with_another_value} = + Member + |> Ash.Changeset.for_create(:create_member, %{ + first_name: "AnotherValue", + last_name: "Test", + email: "another@example.com" + }) + |> Ash.create() + + # Create custom field + {:ok, field} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "test_field", + value_type: :string, + show_in_overview: true + }) + |> Ash.create() + + # Create values: one with actual value, one with empty string, one with NULL (no value), another with value + {:ok, _cfv1} = + CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member_with_value.id, + custom_field_id: field.id, + value: %{"_union_type" => "string", "_union_value" => "Apple"} + }) + |> Ash.create() + + {:ok, _cfv2} = + CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member_with_empty.id, + custom_field_id: field.id, + value: %{"_union_type" => "string", "_union_value" => ""} + }) + |> Ash.create() + + # member_with_null has no custom field value (NULL) + + {:ok, _cfv3} = + CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member_with_another_value.id, + custom_field_id: field.id, + value: %{"_union_type" => "string", "_union_value" => "Zebra"} + }) + |> Ash.create() + + conn = conn_with_oidc_user(conn) + {:ok, view, _html} = live(conn, "/members?query=&sort_field=custom_field_#{field.id}&sort_order=desc") + + html = render(view) + + # Find positions of member first names in the HTML to verify sort order + apple_pos = :binary.match(html, member_with_value.first_name) + zebra_pos = :binary.match(html, member_with_another_value.first_name) + empty_pos = :binary.match(html, member_with_empty.first_name) + null_pos = :binary.match(html, member_with_null.first_name) + + assert apple_pos != :nomatch, "WithValue (Apple) should be in HTML" + assert zebra_pos != :nomatch, "AnotherValue (Zebra) should be in HTML" + assert empty_pos != :nomatch, "WithEmpty should be in HTML" + assert null_pos != :nomatch, "WithNull should be in HTML" + + {apple_idx, _} = apple_pos + {zebra_idx, _} = zebra_pos + {empty_idx, _} = empty_pos + {null_idx, _} = null_pos + + # In DESC order: Zebra should come before Apple + assert zebra_idx < apple_idx, "Zebra should come before Apple in DESC order" + + # NULL and empty should come after all values + assert zebra_idx < empty_idx, "Zebra should come before empty value" + assert zebra_idx < null_idx, "Zebra should come before NULL value" + assert apple_idx < empty_idx, "Apple should come before empty value" + assert apple_idx < null_idx, "Apple should come before NULL value" + end +end From ad2ab7b1d9b06de8d584a58896c6a6a3452c5665 Mon Sep 17 00:00:00 2001 From: carla Date: Wed, 26 Nov 2025 18:15:14 +0100 Subject: [PATCH 14/26] chore: show in overview attribute to custom field --- lib/membership/custom_field.ex | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/lib/membership/custom_field.ex b/lib/membership/custom_field.ex index e1cf397..5b7514c 100644 --- a/lib/membership/custom_field.ex +++ b/lib/membership/custom_field.ex @@ -14,6 +14,7 @@ defmodule Mv.Membership.CustomField do - `description` - Optional human-readable description - `immutable` - If true, custom field values cannot be changed after creation - `required` - If true, all members must have this custom field (future feature) + - `show_in_overview` - If true, this custom field will be displayed in the member overview table and can be sorted ## Supported Value Types - `:string` - Text data (max 10,000 characters) @@ -59,10 +60,10 @@ defmodule Mv.Membership.CustomField do actions do defaults [:read, :update] - default_accept [:name, :value_type, :description, :immutable, :required] + default_accept [:name, :value_type, :description, :immutable, :required, :show_in_overview] create :create do - accept [:name, :value_type, :description, :immutable, :required] + accept [:name, :value_type, :description, :immutable, :required, :show_in_overview] change Mv.Membership.CustomField.Changes.GenerateSlug validate string_length(:slug, min: 1) end @@ -119,6 +120,12 @@ defmodule Mv.Membership.CustomField do attribute :required, :boolean, default: false, allow_nil?: false + + attribute :show_in_overview, :boolean, + default: true, + allow_nil?: false, + public?: true, + description: "If true, this custom field will be displayed in the member overview table" end relationships do From 1c8b5df1051dbd9a3f1d446995c80a0a5c81fcf5 Mon Sep 17 00:00:00 2001 From: carla Date: Wed, 26 Nov 2025 18:18:27 +0100 Subject: [PATCH 15/26] feat: adds dynamic cols to table core component --- lib/mv_web/components/core_components.ex | 33 ++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/lib/mv_web/components/core_components.ex b/lib/mv_web/components/core_components.ex index 656d3c0..24e5ffe 100644 --- a/lib/mv_web/components/core_components.ex +++ b/lib/mv_web/components/core_components.ex @@ -318,6 +318,13 @@ defmodule MvWeb.CoreComponents do default: &Function.identity/1, doc: "the function for mapping each row before calling the :col and :action slots" + attr :dynamic_cols, :list, + default: [], + doc: "list of dynamic column definitions with :custom_field and :render functions" + + attr :sort_field, :any, default: nil, doc: "current sort field" + attr :sort_order, :atom, default: nil, doc: "current sort order" + slot :col, required: true do attr :label, :string end @@ -335,6 +342,16 @@ defmodule MvWeb.CoreComponents do {col[:label]} + + <.live_component + module={MvWeb.Components.SortHeaderComponent} + id={:"sort_custom_field_#{dyn_col[:custom_field].id}"} + field={"custom_field_#{dyn_col[:custom_field].id}"} + label={dyn_col[:custom_field].name} + sort_field={@sort_field} + sort_order={@sort_order} + /> + {gettext("Actions")} @@ -349,6 +366,22 @@ defmodule MvWeb.CoreComponents do > {render_slot(col, @row_item.(row))} + + {if dyn_col[:render] do + rendered = dyn_col[:render].(@row_item.(row)) + if rendered == "" do + "" + else + rendered + end + else + "" + end} +
<%= for action <- @action do %> From c974be9ee2dc887ebcbe03997ded0b885b66f1a7 Mon Sep 17 00:00:00 2001 From: carla Date: Wed, 26 Nov 2025 18:18:58 +0100 Subject: [PATCH 16/26] feat: add dynamic cols to member overview and checkbox to form --- docs/feature-roadmap.md | 7 +- lib/mv_web/live/custom_field_live/form.ex | 2 + lib/mv_web/live/member_live/index.ex | 407 ++++++++++++++++-- lib/mv_web/live/member_live/index.html.heex | 4 +- .../live/member_live/index/formatter.ex | 78 ++++ 5 files changed, 460 insertions(+), 38 deletions(-) create mode 100644 lib/mv_web/live/member_live/index/formatter.ex diff --git a/docs/feature-roadmap.md b/docs/feature-roadmap.md index 9a6517d..2313fd7 100644 --- a/docs/feature-roadmap.md +++ b/docs/feature-roadmap.md @@ -94,15 +94,18 @@ - ✅ CustomFieldValue type management - ✅ Dynamic custom field value assignment to members - ✅ Union type storage (JSONB) +- ✅ Default field visibility configuration + +**Closed Issues:** +- [#194](https://git.local-it.org/local-it/mitgliederverwaltung/issues/194) - Custom Fields: Harden implementation (S) +- [#197](https://git.local-it.org/local-it/mitgliederverwaltung/issues/197) - Custom Fields: Add option to show custom fields in member overview (M) **Open Issues:** -- [#194](https://git.local-it.org/local-it/mitgliederverwaltung/issues/194) - Custom Fields: Harden implementation (S) [0/3 tasks] - [#157](https://git.local-it.org/local-it/mitgliederverwaltung/issues/157) - Concept how custom fields are handled (M, High priority) [0/4 tasks] - [#161](https://git.local-it.org/local-it/mitgliederverwaltung/issues/161) - Don't show birthday field for default configurations (S, Low priority) - [#153](https://git.local-it.org/local-it/mitgliederverwaltung/issues/153) - Sorting functionalities for custom fields (M, Low priority) **Missing Features:** -- ❌ Default field visibility configuration - ❌ Field groups/categories - ❌ Conditional fields (show field X if field Y = value) - ❌ Field validation rules (min/max, regex patterns) diff --git a/lib/mv_web/live/custom_field_live/form.ex b/lib/mv_web/live/custom_field_live/form.ex index ab8f104..99317a9 100644 --- a/lib/mv_web/live/custom_field_live/form.ex +++ b/lib/mv_web/live/custom_field_live/form.ex @@ -18,6 +18,7 @@ defmodule MvWeb.CustomFieldLive.Form do - description - Human-readable explanation - immutable - If true, values cannot be changed after creation (default: false) - required - If true, all members must have this custom field (default: false) + - show_in_overview - If true, this custom field will be displayed in the member overview table (default: true) ## Value Type Selection - `:string` - Text data (unlimited length) @@ -60,6 +61,7 @@ defmodule MvWeb.CustomFieldLive.Form do <.input field={@form[:description]} type="text" label={gettext("Description")} /> <.input field={@form[:immutable]} type="checkbox" label={gettext("Immutable")} /> <.input field={@form[:required]} type="checkbox" label={gettext("Required")} /> + <.input field={@form[:show_in_overview]} type="checkbox" label={gettext("Show in overview")} /> <.button phx-disable-with={gettext("Saving...")} variant="primary"> {gettext("Save Custom field")} diff --git a/lib/mv_web/live/member_live/index.ex b/lib/mv_web/live/member_live/index.ex index c933133..4a134f2 100644 --- a/lib/mv_web/live/member_live/index.ex +++ b/lib/mv_web/live/member_live/index.ex @@ -26,6 +26,11 @@ defmodule MvWeb.MemberLive.Index do """ use MvWeb, :live_view + alias MvWeb.MemberLive.Index.Formatter + + # Prefix used in sort field names for custom fields (e.g., "custom_field_") + @custom_field_prefix "custom_field_" + @doc """ Initializes the LiveView state. @@ -34,6 +39,19 @@ defmodule MvWeb.MemberLive.Index do """ @impl true def mount(_params, _session, socket) do + # Load custom fields that should be shown in overview + require Ash.Query + import Ash.Expr + + # Note: Using Ash.read! (bang version) - errors will be handled by Phoenix LiveView + # and result in a 500 error page. This is appropriate for LiveViews where errors + # should be visible to the user rather than silently failing. + custom_fields_visible = + Mv.Membership.CustomField + |> Ash.Query.filter(expr(show_in_overview == true)) + |> Ash.Query.sort(name: :asc) + |> Ash.read!() + socket = socket |> assign(:page_title, gettext("Members")) @@ -41,6 +59,7 @@ defmodule MvWeb.MemberLive.Index do |> assign_new(:sort_field, fn -> :first_name end) |> assign_new(:sort_order, fn -> :asc end) |> assign(:selected_members, []) + |> assign(:custom_fields_visible, custom_fields_visible) # We call handle params to use the query from the URL {:ok, socket} @@ -60,6 +79,8 @@ defmodule MvWeb.MemberLive.Index do """ @impl true def handle_event("delete", %{"id" => id}, socket) do + # Note: Using bang versions (!) - errors will be handled by Phoenix LiveView + # This ensures users see error messages if deletion fails (e.g., permission denied) member = Ash.get!(Mv.Membership.Member, id) Ash.destroy!(member) @@ -108,7 +129,14 @@ defmodule MvWeb.MemberLive.Index do """ @impl true def handle_info({:sort, field_str}, socket) do - field = String.to_existing_atom(field_str) + # Handle both atom and string field names (for custom fields) + field = + try do + String.to_existing_atom(field_str) + rescue + ArgumentError -> field_str + end + {new_field, new_order} = determine_new_sort(field, socket) socket @@ -158,10 +186,37 @@ defmodule MvWeb.MemberLive.Index do |> maybe_update_search(params) |> maybe_update_sort(params) |> load_members(params["query"]) + |> prepare_dynamic_cols() {:noreply, socket} end + # Prepares dynamic column definitions for custom fields that should be shown in the overview. + # + # Creates a list of column definitions, each containing: + # - `:custom_field` - The CustomField resource + # - `:render` - A function that formats the custom field value for a given member + # + # Returns the socket with `:dynamic_cols` assigned. + defp prepare_dynamic_cols(socket) do + dynamic_cols = + Enum.map(socket.assigns.custom_fields_visible, fn custom_field -> + %{ + custom_field: custom_field, + render: fn member -> + case get_custom_field_value(member, custom_field) do + nil -> "" + cfv -> + formatted = Formatter.format_custom_field_value(cfv.value, custom_field) + if formatted == "", do: "", else: formatted + end + end + } + end) + + assign(socket, :dynamic_cols, dynamic_cols) + end + # ------------------------------------------------------------- # FUNCTIONS # ------------------------------------------------------------- @@ -177,8 +232,8 @@ defmodule MvWeb.MemberLive.Index do # Updates both the active and old SortHeader components defp update_sort_components(socket, old_field, new_field, new_order) do - active_id = :"sort_#{new_field}" - old_id = :"sort_#{old_field}" + active_id = to_sort_id(new_field) + old_id = to_sort_id(old_field) # Update the new SortHeader send_update(MvWeb.Components.SortHeaderComponent, @@ -197,11 +252,32 @@ defmodule MvWeb.MemberLive.Index do socket end + # Converts a field (atom or string) to a sort component ID atom + # Handles both existing atoms and strings that need to be converted + defp to_sort_id(field) when is_binary(field) do + try do + String.to_existing_atom("sort_#{field}") + rescue + ArgumentError -> :"sort_#{field}" + end + end + + defp to_sort_id(field) when is_atom(field) do + :"sort_#{field}" + end + # Builds sort URL and pushes navigation patch defp push_sort_url(socket, field, order) do + field_str = + if is_atom(field) do + Atom.to_string(field) + else + field + end + query_params = %{ "query" => socket.assigns.query, - "sort_field" => Atom.to_string(field), + "sort_field" => field_str, "sort_order" => Atom.to_string(order) } @@ -214,7 +290,25 @@ defmodule MvWeb.MemberLive.Index do )} end - # Load members eg based on a query for sorting + # Loads members from the database with custom field values and applies search/sort filters. + # + # Process: + # 1. Builds base query with selected fields + # 2. Loads custom field values for visible custom fields + # 3. Applies search filter if provided + # 4. Applies sorting (database-level for regular fields, in-memory for custom fields) + # 5. Filters custom field values to only visible ones (reduces memory usage) + # + # Performance Considerations: + # - In-memory sorting: Custom field sorting is done in memory after loading. + # This is suitable for small to medium datasets (<1000 members). + # For larger datasets, consider implementing database-level sorting or pagination. + # - Memory filtering: Custom field values are filtered after loading to reduce + # memory usage, but all members are still loaded into memory. + # - No pagination: All matching members are loaded at once. For large result sets, + # consider implementing pagination (see Issue #165). + # + # Returns the socket with `:members` assigned. defp load_members(socket, search_query) do query = Mv.Membership.Member @@ -232,16 +326,61 @@ defmodule MvWeb.MemberLive.Index do :join_date ]) + # Load custom field values for visible custom fields + custom_field_ids = Enum.map(socket.assigns.custom_fields_visible, & &1.id) + query = load_custom_field_values(query, custom_field_ids) + # Apply the search filter first query = apply_search_filter(query, search_query) # Apply sorting based on current socket state - query = maybe_sort(query, socket.assigns.sort_field, socket.assigns.sort_order) + # For custom fields, we sort after loading + {query, sort_after_load} = maybe_sort(query, socket.assigns.sort_field, socket.assigns.sort_order, socket.assigns.custom_fields_visible) + # Note: Using Ash.read! - errors will be handled by Phoenix LiveView + # This is appropriate for data loading in LiveViews members = Ash.read!(query) + + # Filter custom field values to only visible ones (reduces memory usage) + # Performance: This iterates through all members and their custom_field_values. + # For large datasets (>1000 members), this could be optimized by filtering + # at the database level, but requires more complex Ash queries. + custom_field_ids = MapSet.new(Enum.map(socket.assigns.custom_fields_visible, & &1.id)) + members = Enum.map(members, fn member -> + # Only filter if custom_field_values is loaded (is a list, not Ash.NotLoaded) + if is_list(member.custom_field_values) do + filtered_values = Enum.filter(member.custom_field_values, fn cfv -> + cfv.custom_field_id in custom_field_ids + end) + %{member | custom_field_values: filtered_values} + else + member + end + end) + + # Sort in memory if needed (for custom fields) + members = if sort_after_load do + sort_members_in_memory(members, socket.assigns.sort_field, socket.assigns.sort_order, socket.assigns.custom_fields_visible) + else + members + end + assign(socket, :members, members) end + # Load custom field values for the given custom field IDs + defp load_custom_field_values(query, []) do + query + end + + defp load_custom_field_values(query, custom_field_ids) when length(custom_field_ids) > 0 do + # Load all custom field values with their custom_field relationship + # Note: We filter to visible custom fields after loading to reduce memory usage + # Ash loads relationships efficiently with JOINs, but we only keep visible ones + query + |> Ash.Query.load(custom_field_values: [custom_field: [:id, :name, :value_type]]) + end + # ------------------------------------------------------------- # Helper Functions # ------------------------------------------------------------- @@ -264,15 +403,24 @@ defmodule MvWeb.MemberLive.Index do defp toggle_order(nil), do: :asc # Function to sort the column if needed - defp maybe_sort(query, nil, _), do: query + # Returns {query, sort_after_load} where sort_after_load is true if we need to sort in memory + defp maybe_sort(query, nil, _, _), do: {query, false} - defp maybe_sort(query, field, :asc) when not is_nil(field), - do: Ash.Query.sort(query, [{field, :asc}]) + defp maybe_sort(query, field, order, _custom_fields) when not is_nil(field) do + if custom_field_sort?(field) do + # Custom fields need to be sorted in memory after loading + {query, true} + else + # Only sort by atom fields (regular member fields) in database + if is_atom(field) do + {Ash.Query.sort(query, [{field, order}]), false} + else + {query, false} + end + end + end - defp maybe_sort(query, field, :desc) when not is_nil(field), - do: Ash.Query.sort(query, [{field, :desc}]) - - defp maybe_sort(query, _, _), do: query + defp maybe_sort(query, _, _, _), do: {query, false} # Validate that a field is sortable defp valid_sort_field?(field) when is_atom(field) do @@ -288,12 +436,156 @@ defmodule MvWeb.MemberLive.Index do :join_date ] - field in valid_fields + field in valid_fields or custom_field_sort?(field) + end + + defp valid_sort_field?(field) when is_binary(field) do + custom_field_sort?(field) end defp valid_sort_field?(_), do: false - # Function to maybe update the sort + # Check if field is a custom field sort field (format: custom_field_) + defp custom_field_sort?(field) when is_atom(field) do + field_str = Atom.to_string(field) + String.starts_with?(field_str, @custom_field_prefix) + end + + defp custom_field_sort?(field) when is_binary(field) do + String.starts_with?(field, @custom_field_prefix) + end + + defp custom_field_sort?(_), do: false + + # Extracts the custom field ID from a sort field name. + # + # Sort fields for custom fields use the format: "custom_field_" + # This function extracts the ID part. + # + # Examples: + # extract_custom_field_id("custom_field_123") -> "123" + # extract_custom_field_id(:custom_field_123) -> "123" + # extract_custom_field_id("first_name") -> nil + defp extract_custom_field_id(field) when is_atom(field) do + field_str = Atom.to_string(field) + extract_custom_field_id(field_str) + end + + defp extract_custom_field_id(field) when is_binary(field) do + case String.split(field, @custom_field_prefix) do + ["", id_str] -> id_str + _ -> nil + end + end + + defp extract_custom_field_id(_), do: nil + + # Sorts members in memory by a custom field value. + # + # Process: + # 1. Extracts custom field ID from sort field name + # 2. Finds the corresponding CustomField resource + # 3. Splits members into those with values and those without + # 4. Sorts members with values by the extracted value + # 5. Combines: sorted values first, then NULL/empty values at the end + # + # Performance Note: + # This function sorts in memory, which is suitable for small to medium datasets (<1000 members). + # For larger datasets, consider implementing database-level sorting or pagination. + # + # Parameters: + # - `members` - List of Member resources to sort + # - `field` - Sort field name (format: "custom_field_" or atom) + # - `order` - Sort order (`:asc` or `:desc`) + # - `custom_fields` - List of visible CustomField resources + # + # Returns the sorted list of members. + defp sort_members_in_memory(members, field, order, custom_fields) do + custom_field_id_str = extract_custom_field_id(field) + + case custom_field_id_str do + nil -> + members + + id_str -> + # Find the custom field by matching the ID string + custom_field = + Enum.find(custom_fields, fn cf -> + to_string(cf.id) == id_str + end) + + case custom_field do + nil -> + members + + cf -> + # Split members into those with values and those without (NULL/empty) + {members_with_values, members_without_values} = + Enum.split_with(members, fn member -> + case get_custom_field_value(member, cf) do + nil -> false + cfv -> + extracted = extract_sort_value(cfv.value, cf.value_type) + not is_empty_value(extracted, cf.value_type) + end + end) + + # Sort members with values + sorted_with_values = Enum.sort_by(members_with_values, fn member -> + cfv = get_custom_field_value(member, cf) + extracted = extract_sort_value(cfv.value, cf.value_type) + normalize_sort_value(extracted, order) + end) + + # For DESC, reverse only the members with values + sorted_with_values = if order == :desc do + Enum.reverse(sorted_with_values) + else + sorted_with_values + end + + # Combine: sorted values first, then NULL/empty values at the end + sorted_with_values ++ members_without_values + end + end + end + + # Extracts a sortable value from a custom field value based on its type. + # + # Handles different value formats: + # - `%Ash.Union{}` - Extracts value and type from union + # - Direct values - Returns as-is for primitive types + # + # Returns the extracted value suitable for sorting. + defp extract_sort_value(%Ash.Union{value: value, type: type}, _expected_type) do + extract_sort_value(value, type) + end + + defp extract_sort_value(value, :string) when is_binary(value), do: value + defp extract_sort_value(value, :integer) when is_integer(value), do: value + defp extract_sort_value(value, :boolean) when is_boolean(value), do: value + defp extract_sort_value(%Date{} = date, :date), do: date + defp extract_sort_value(value, :email) when is_binary(value), do: value + defp extract_sort_value(value, _type), do: to_string(value) + + # Check if a value is considered empty (NULL or empty string) + defp is_empty_value(value, :string) when is_binary(value) do + String.trim(value) == "" + end + defp is_empty_value(value, :email) when is_binary(value) do + String.trim(value) == "" + end + defp is_empty_value(_value, _type), do: false + + # Normalize sort value for DESC order + # For DESC, we sort ascending first, then reverse the list + # This function is kept for consistency but doesn't need to invert values + defp normalize_sort_value(value, _order), do: value + + + # Updates sort field and order from URL parameters if present. + # + # Validates the sort field and order, falling back to defaults if invalid. defp maybe_update_sort(socket, %{"sort_field" => sf, "sort_order" => so}) do field = determine_field(socket.assigns.sort_field, sf) order = determine_order(socket.assigns.sort_order, so) @@ -305,33 +597,50 @@ defmodule MvWeb.MemberLive.Index do defp maybe_update_sort(socket, _), do: socket - defp determine_field(default, sf) do - case sf do - "" -> - default + # Determine sort field from URL parameter, validating against allowed fields + defp determine_field(default, ""), do: default + defp determine_field(default, nil), do: default - nil -> - default - - sf when is_binary(sf) -> - sf - |> String.to_existing_atom() - |> handle_atom_conversion(default) - - sf when is_atom(sf) -> - handle_atom_conversion(sf, default) - - _ -> - default + # Determines the valid sort field from a URL parameter. + # + # Validates the field against allowed sort fields (regular member fields or custom fields). + # Falls back to default if the field is invalid. + # + # Parameters: + # - `default` - Default field to use if validation fails + # - `sf` - Sort field from URL (can be atom, string, nil, or empty string) + # + # Returns a valid sort field (atom or string for custom fields). + defp determine_field(default, sf) when is_binary(sf) do + # Check if it's a custom field sort (starts with "custom_field_") + if custom_field_sort?(sf) do + if valid_sort_field?(sf), do: sf, else: default + else + # Try to convert to atom for regular fields + try do + atom = String.to_existing_atom(sf) + if valid_sort_field?(atom), do: atom, else: default + rescue + ArgumentError -> default + end end end - defp handle_atom_conversion(val, default) when is_atom(val) do - if valid_sort_field?(val), do: val, else: default + defp determine_field(default, sf) when is_atom(sf) do + if valid_sort_field?(sf), do: sf, else: default end - defp handle_atom_conversion(_, default), do: default + defp determine_field(default, _), do: default + # Determines the valid sort order from a URL parameter. + # + # Validates that the order is either "asc" or "desc", falling back to default if invalid. + # + # Parameters: + # - `default` - Default order to use if validation fails + # - `so` - Sort order from URL (string, atom, nil, or empty string) + # + # Returns `:asc` or `:desc`. defp determine_order(default, so) do case so do "" -> default @@ -350,4 +659,32 @@ defmodule MvWeb.MemberLive.Index do # Keep the previous search query if no new one is provided socket end + + # ------------------------------------------------------------- + # Helper Functions for Custom Field Values + # ------------------------------------------------------------- + + # Retrieves the custom field value for a specific member and custom field. + # + # Searches through the member's `custom_field_values` relationship to find + # the value matching the given custom field. + # + # Returns: + # - `%CustomFieldValue{}` if found + # - `nil` if not found or if member has no custom field values + # + # Examples: + # get_custom_field_value(member, custom_field) -> %CustomFieldValue{...} + # get_custom_field_value(member, non_existent_field) -> nil + def get_custom_field_value(member, custom_field) do + case member.custom_field_values do + nil -> nil + values when is_list(values) -> + Enum.find(values, fn cfv -> + cfv.custom_field_id == custom_field.id or + (cfv.custom_field && cfv.custom_field.id == custom_field.id) + end) + _ -> nil + end + end end diff --git a/lib/mv_web/live/member_live/index.html.heex b/lib/mv_web/live/member_live/index.html.heex index cb2ccd8..67fa804 100644 --- a/lib/mv_web/live/member_live/index.html.heex +++ b/lib/mv_web/live/member_live/index.html.heex @@ -19,6 +19,9 @@ id="members" rows={@members} row_click={fn member -> JS.navigate(~p"/members/#{member}") end} + dynamic_cols={@dynamic_cols} + sort_field={@sort_field} + sort_order={@sort_order} > @@ -185,7 +188,6 @@ > {member.join_date} - <:action :let={member}>
<.link navigate={~p"/members/#{member}"}>{gettext("Show")} diff --git a/lib/mv_web/live/member_live/index/formatter.ex b/lib/mv_web/live/member_live/index/formatter.ex new file mode 100644 index 0000000..d97966c --- /dev/null +++ b/lib/mv_web/live/member_live/index/formatter.ex @@ -0,0 +1,78 @@ +defmodule MvWeb.MemberLive.Index.Formatter do + @moduledoc """ + Formats custom field values for display in the member overview table. + + Handles different value types (string, integer, boolean, date, email) and + formats them appropriately for display in the UI. + """ + use Gettext, backend: MvWeb.Gettext + + @doc """ + Formats a custom field value for display. + + Handles different input formats: + - `nil` - Returns empty string + - `%Ash.Union{}` - Extracts value and type from union type + - Map (JSONB format) - Extracts type and value from map keys + - Direct value - Uses custom_field.value_type to determine format + + ## Examples + + iex> format_custom_field_value(nil, %CustomField{value_type: :string}) + "" + + iex> format_custom_field_value("test", %CustomField{value_type: :string}) + "test" + + iex> format_custom_field_value(true, %CustomField{value_type: :boolean}) + "Yes" + """ + def format_custom_field_value(nil, _custom_field), do: "" + + def format_custom_field_value(%Ash.Union{value: value, type: type}, custom_field) do + format_value_by_type(value, type, custom_field) + end + + def format_custom_field_value(value, custom_field) when is_map(value) do + # Handle map format from JSONB + type = Map.get(value, "type") || Map.get(value, "_union_type") + val = Map.get(value, "value") || Map.get(value, "_union_value") + format_value_by_type(val, type, custom_field) + end + + def format_custom_field_value(value, custom_field) do + format_value_by_type(value, custom_field.value_type, custom_field) + end + + # Format value based on type + defp format_value_by_type(value, :string, _) when is_binary(value) do + # Return empty string if value is empty, otherwise return the value + if String.trim(value) == "", do: "", else: value + end + + defp format_value_by_type(value, :string, _), do: to_string(value) + + defp format_value_by_type(value, :integer, _), do: to_string(value) + + defp format_value_by_type(value, :email, _) when is_binary(value) do + # Return empty string if value is empty + if String.trim(value) == "", do: "", else: value + end + + defp format_value_by_type(value, :email, _), do: to_string(value) + + defp format_value_by_type(value, :boolean, _) when value == true, do: gettext("Yes") + defp format_value_by_type(value, :boolean, _) when value == false, do: gettext("No") + defp format_value_by_type(value, :boolean, _), do: to_string(value) + + defp format_value_by_type(%Date{} = date, :date, _), do: Date.to_string(date) + + defp format_value_by_type(value, :date, _) when is_binary(value) do + case Date.from_iso8601(value) do + {:ok, date} -> Date.to_string(date) + _ -> value + end + end + + defp format_value_by_type(value, _type, _), do: to_string(value) +end From 631cf23a0f8bd0c485cada14acc3d42de9c5ccc2 Mon Sep 17 00:00:00 2001 From: carla Date: Thu, 27 Nov 2025 14:10:27 +0100 Subject: [PATCH 17/26] formatting --- lib/mv_web/components/core_components.ex | 1 + lib/mv_web/live/member_live/index.ex | 179 ++++++++++++------ ...index_custom_fields_accessibility_test.exs | 8 +- .../index_custom_fields_display_test.exs | 2 +- .../index_custom_fields_edge_cases_test.exs | 1 - .../index_custom_fields_sorting_test.exs | 25 ++- 6 files changed, 148 insertions(+), 68 deletions(-) diff --git a/lib/mv_web/components/core_components.ex b/lib/mv_web/components/core_components.ex index 24e5ffe..b8fe0fc 100644 --- a/lib/mv_web/components/core_components.ex +++ b/lib/mv_web/components/core_components.ex @@ -373,6 +373,7 @@ defmodule MvWeb.CoreComponents do > {if dyn_col[:render] do rendered = dyn_col[:render].(@row_item.(row)) + if rendered == "" do "" else diff --git a/lib/mv_web/live/member_live/index.ex b/lib/mv_web/live/member_live/index.ex index 4a134f2..419df17 100644 --- a/lib/mv_web/live/member_live/index.ex +++ b/lib/mv_web/live/member_live/index.ex @@ -205,7 +205,9 @@ defmodule MvWeb.MemberLive.Index do custom_field: custom_field, render: fn member -> case get_custom_field_value(member, custom_field) do - nil -> "" + nil -> + "" + cfv -> formatted = Formatter.format_custom_field_value(cfv.value, custom_field) if formatted == "", do: "", else: formatted @@ -335,7 +337,13 @@ defmodule MvWeb.MemberLive.Index do # Apply sorting based on current socket state # For custom fields, we sort after loading - {query, sort_after_load} = maybe_sort(query, socket.assigns.sort_field, socket.assigns.sort_order, socket.assigns.custom_fields_visible) + {query, sort_after_load} = + maybe_sort( + query, + socket.assigns.sort_field, + socket.assigns.sort_order, + socket.assigns.custom_fields_visible + ) # Note: Using Ash.read! - errors will be handled by Phoenix LiveView # This is appropriate for data loading in LiveViews @@ -346,24 +354,21 @@ defmodule MvWeb.MemberLive.Index do # For large datasets (>1000 members), this could be optimized by filtering # at the database level, but requires more complex Ash queries. custom_field_ids = MapSet.new(Enum.map(socket.assigns.custom_fields_visible, & &1.id)) - members = Enum.map(members, fn member -> - # Only filter if custom_field_values is loaded (is a list, not Ash.NotLoaded) - if is_list(member.custom_field_values) do - filtered_values = Enum.filter(member.custom_field_values, fn cfv -> - cfv.custom_field_id in custom_field_ids - end) - %{member | custom_field_values: filtered_values} - else - member - end - end) + + members = filter_member_custom_field_values(members, custom_field_ids) # Sort in memory if needed (for custom fields) - members = if sort_after_load do - sort_members_in_memory(members, socket.assigns.sort_field, socket.assigns.sort_order, socket.assigns.custom_fields_visible) - else - members - end + members = + if sort_after_load do + sort_members_in_memory( + members, + socket.assigns.sort_field, + socket.assigns.sort_order, + socket.assigns.custom_fields_visible + ) + else + members + end assign(socket, :members, members) end @@ -381,6 +386,28 @@ defmodule MvWeb.MemberLive.Index do |> Ash.Query.load(custom_field_values: [custom_field: [:id, :name, :value_type]]) end + # Filters custom field values to only visible ones for all members + defp filter_member_custom_field_values(members, custom_field_ids) do + Enum.map(members, fn member -> + filter_single_member_custom_field_values(member, custom_field_ids) + end) + end + + # Filters custom field values for a single member + defp filter_single_member_custom_field_values(member, _custom_field_ids) + when not is_list(member.custom_field_values) do + member + end + + defp filter_single_member_custom_field_values(member, custom_field_ids) do + filtered_values = + Enum.filter(member.custom_field_values, fn cfv -> + cfv.custom_field_id in custom_field_ids + end) + + %{member | custom_field_values: filtered_values} + end + # ------------------------------------------------------------- # Helper Functions # ------------------------------------------------------------- @@ -508,45 +535,76 @@ defmodule MvWeb.MemberLive.Index do members id_str -> - # Find the custom field by matching the ID string - custom_field = - Enum.find(custom_fields, fn cf -> - to_string(cf.id) == id_str - end) + sort_members_by_custom_field(members, id_str, order, custom_fields) + end + end - case custom_field do - nil -> - members + # Sorts members by a specific custom field ID + defp sort_members_by_custom_field(members, id_str, order, custom_fields) do + custom_field = find_custom_field_by_id(custom_fields, id_str) - cf -> - # Split members into those with values and those without (NULL/empty) - {members_with_values, members_without_values} = - Enum.split_with(members, fn member -> - case get_custom_field_value(member, cf) do - nil -> false - cfv -> - extracted = extract_sort_value(cfv.value, cf.value_type) - not is_empty_value(extracted, cf.value_type) - end - end) + case custom_field do + nil -> + members - # Sort members with values - sorted_with_values = Enum.sort_by(members_with_values, fn member -> - cfv = get_custom_field_value(member, cf) - extracted = extract_sort_value(cfv.value, cf.value_type) - normalize_sort_value(extracted, order) - end) + cf -> + sort_members_with_custom_field(members, cf, order) + end + end - # For DESC, reverse only the members with values - sorted_with_values = if order == :desc do - Enum.reverse(sorted_with_values) - else - sorted_with_values - end + # Finds a custom field by matching its ID string + defp find_custom_field_by_id(custom_fields, id_str) do + Enum.find(custom_fields, fn cf -> + to_string(cf.id) == id_str + end) + end - # Combine: sorted values first, then NULL/empty values at the end - sorted_with_values ++ members_without_values - end + # Sorts members that have a specific custom field + defp sort_members_with_custom_field(members, custom_field, order) do + # Split members into those with values and those without (NULL/empty) + {members_with_values, members_without_values} = + split_members_by_value_presence(members, custom_field) + + # Sort members with values + sorted_with_values = sort_members_with_values(members_with_values, custom_field, order) + + # Combine: sorted values first, then NULL/empty values at the end + sorted_with_values ++ members_without_values + end + + # Splits members into those with values and those without + defp split_members_by_value_presence(members, custom_field) do + Enum.split_with(members, fn member -> + has_non_empty_value?(member, custom_field) + end) + end + + # Checks if a member has a non-empty value for the custom field + defp has_non_empty_value?(member, custom_field) do + case get_custom_field_value(member, custom_field) do + nil -> + false + + cfv -> + extracted = extract_sort_value(cfv.value, custom_field.value_type) + not empty_value?(extracted, custom_field.value_type) + end + end + + # Sorts members that have values for the custom field + defp sort_members_with_values(members_with_values, custom_field, order) do + sorted = + Enum.sort_by(members_with_values, fn member -> + cfv = get_custom_field_value(member, custom_field) + extracted = extract_sort_value(cfv.value, custom_field.value_type) + normalize_sort_value(extracted, order) + end) + + # For DESC, reverse only the members with values + if order == :desc do + Enum.reverse(sorted) + else + sorted end end @@ -569,20 +627,21 @@ defmodule MvWeb.MemberLive.Index do defp extract_sort_value(value, _type), do: to_string(value) # Check if a value is considered empty (NULL or empty string) - defp is_empty_value(value, :string) when is_binary(value) do + defp empty_value?(value, :string) when is_binary(value) do String.trim(value) == "" end - defp is_empty_value(value, :email) when is_binary(value) do + + defp empty_value?(value, :email) when is_binary(value) do String.trim(value) == "" end - defp is_empty_value(_value, _type), do: false + + defp empty_value?(_value, _type), do: false # Normalize sort value for DESC order # For DESC, we sort ascending first, then reverse the list # This function is kept for consistency but doesn't need to invert values defp normalize_sort_value(value, _order), do: value - # Updates sort field and order from URL parameters if present. # # Validates the sort field and order, falling back to defaults if invalid. @@ -678,13 +737,17 @@ defmodule MvWeb.MemberLive.Index do # get_custom_field_value(member, non_existent_field) -> nil def get_custom_field_value(member, custom_field) do case member.custom_field_values do - nil -> nil + nil -> + nil + values when is_list(values) -> Enum.find(values, fn cfv -> cfv.custom_field_id == custom_field.id or (cfv.custom_field && cfv.custom_field.id == custom_field.id) end) - _ -> nil + + _ -> + nil end end end diff --git a/test/mv_web/member_live/index_custom_fields_accessibility_test.exs b/test/mv_web/member_live/index_custom_fields_accessibility_test.exs index e4d174f..cfe3145 100644 --- a/test/mv_web/member_live/index_custom_fields_accessibility_test.exs +++ b/test/mv_web/member_live/index_custom_fields_accessibility_test.exs @@ -67,7 +67,9 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsAccessibilityTest do field: field } do conn = conn_with_oidc_user(conn) - {:ok, view, _html} = live(conn, "/members?query=&sort_field=custom_field_#{field.id}&sort_order=asc") + + {:ok, view, _html} = + live(conn, "/members?query=&sort_field=custom_field_#{field.id}&sort_order=asc") html = render(view) @@ -80,7 +82,9 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsAccessibilityTest do field: field } do conn = conn_with_oidc_user(conn) - {:ok, view, _html} = live(conn, "/members?query=&sort_field=custom_field_#{field.id}&sort_order=desc") + + {:ok, view, _html} = + live(conn, "/members?query=&sort_field=custom_field_#{field.id}&sort_order=desc") html = render(view) diff --git a/test/mv_web/member_live/index_custom_fields_display_test.exs b/test/mv_web/member_live/index_custom_fields_display_test.exs index 7788c60..25aefe5 100644 --- a/test/mv_web/member_live/index_custom_fields_display_test.exs +++ b/test/mv_web/member_live/index_custom_fields_display_test.exs @@ -105,7 +105,7 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsDisplayTest do |> Ash.Changeset.for_create(:create, %{ member_id: member1.id, custom_field_id: field_show_integer.id, - value: %{"_union_type" => "integer", "_union_value" => 12345} + value: %{"_union_type" => "integer", "_union_value" => 12_345} }) |> Ash.create() diff --git a/test/mv_web/member_live/index_custom_fields_edge_cases_test.exs b/test/mv_web/member_live/index_custom_fields_edge_cases_test.exs index 9d44c40..d526556 100644 --- a/test/mv_web/member_live/index_custom_fields_edge_cases_test.exs +++ b/test/mv_web/member_live/index_custom_fields_edge_cases_test.exs @@ -171,4 +171,3 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsEdgeCasesTest do assert html =~ "Value3" end end - diff --git a/test/mv_web/member_live/index_custom_fields_sorting_test.exs b/test/mv_web/member_live/index_custom_fields_sorting_test.exs index e1c99b2..21b0c9f 100644 --- a/test/mv_web/member_live/index_custom_fields_sorting_test.exs +++ b/test/mv_web/member_live/index_custom_fields_sorting_test.exs @@ -174,7 +174,9 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsSortingTest do test "sorting by custom field works with URL parameters", %{conn: conn, field_string: field} do conn = conn_with_oidc_user(conn) - {:ok, view, _html} = live(conn, "/members?query=&sort_field=custom_field_#{field.id}&sort_order=desc") + + {:ok, view, _html} = + live(conn, "/members?query=&sort_field=custom_field_#{field.id}&sort_order=desc") # Check that the sort state is correctly applied assert has_element?(view, "[data-testid='custom_field_#{field.id}'][aria-label='descending']") @@ -186,14 +188,19 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsSortingTest do field_integer: field_integer } do conn = conn_with_oidc_user(conn) - {:ok, view, _html} = live(conn, "/members?query=&sort_field=custom_field_#{field_string.id}&sort_order=desc") + + {:ok, view, _html} = + live(conn, "/members?query=&sort_field=custom_field_#{field_string.id}&sort_order=desc") # Click on a different custom field column view |> element("[data-testid='custom_field_#{field_integer.id}']") |> render_click() - assert_patch(view, "/members?query=&sort_field=custom_field_#{field_integer.id}&sort_order=asc") + assert_patch( + view, + "/members?query=&sort_field=custom_field_#{field_integer.id}&sort_order=asc" + ) end test "clicking regular column after custom field column works", %{ @@ -201,7 +208,9 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsSortingTest do field_string: field } do conn = conn_with_oidc_user(conn) - {:ok, view, _html} = live(conn, "/members?query=&sort_field=custom_field_#{field.id}&sort_order=desc") + + {:ok, view, _html} = + live(conn, "/members?query=&sort_field=custom_field_#{field.id}&sort_order=desc") # Click on email column view @@ -305,7 +314,9 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsSortingTest do |> Ash.create() conn = conn_with_oidc_user(conn) - {:ok, view, _html} = live(conn, "/members?query=&sort_field=custom_field_#{field.id}&sort_order=asc") + + {:ok, view, _html} = + live(conn, "/members?query=&sort_field=custom_field_#{field.id}&sort_order=asc") html = render(view) @@ -414,7 +425,9 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsSortingTest do |> Ash.create() conn = conn_with_oidc_user(conn) - {:ok, view, _html} = live(conn, "/members?query=&sort_field=custom_field_#{field.id}&sort_order=desc") + + {:ok, view, _html} = + live(conn, "/members?query=&sort_field=custom_field_#{field.id}&sort_order=desc") html = render(view) From 09c580e02dd4de85d520d919c33f57439d44c001 Mon Sep 17 00:00:00 2001 From: carla Date: Thu, 27 Nov 2025 14:14:53 +0100 Subject: [PATCH 18/26] translate: add translation --- priv/gettext/de/LC_MESSAGES/default.po | 63 ++++++++++++++------------ priv/gettext/default.pot | 63 ++++++++++++++------------ priv/gettext/en/LC_MESSAGES/default.po | 63 ++++++++++++++------------ 3 files changed, 105 insertions(+), 84 deletions(-) diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po index 842ab40..eed9c4a 100644 --- a/priv/gettext/de/LC_MESSAGES/default.po +++ b/priv/gettext/de/LC_MESSAGES/default.po @@ -10,12 +10,12 @@ msgid "" msgstr "" "Language: en\n" -#: lib/mv_web/components/core_components.ex:339 +#: lib/mv_web/components/core_components.ex:356 #, elixir-autogen, elixir-format msgid "Actions" msgstr "Aktionen" -#: lib/mv_web/live/member_live/index.html.heex:200 +#: lib/mv_web/live/member_live/index.html.heex:202 #: lib/mv_web/live/user_live/index.html.heex:65 #, 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:145 +#: lib/mv_web/live/member_live/index.html.heex:148 #: 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:202 +#: lib/mv_web/live/member_live/index.html.heex:204 #: lib/mv_web/live/user_live/index.html.heex:67 #, elixir-autogen, elixir-format msgid "Delete" msgstr "Löschen" -#: lib/mv_web/live/member_live/index.html.heex:194 +#: lib/mv_web/live/member_live/index.html.heex:196 #: lib/mv_web/live/user_live/form.ex:141 #: lib/mv_web/live/user_live/index.html.heex:59 #, 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:77 +#: lib/mv_web/live/member_live/index.html.heex:80 #: 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:179 +#: lib/mv_web/live/member_live/index.html.heex:182 #: 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:191 +#: lib/mv_web/live/member_live/index.html.heex:193 #: lib/mv_web/live/user_live/index.html.heex:56 #, elixir-autogen, elixir-format msgid "Show" @@ -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:111 +#: lib/mv_web/live/member_live/index.html.heex:114 #: lib/mv_web/live/member_live/show.ex:61 #, elixir-autogen, elixir-format msgid "House Number" @@ -140,14 +140,14 @@ msgid "Paid" msgstr "Bezahlt" #: lib/mv_web/live/member_live/form.ex:50 -#: lib/mv_web/live/member_live/index.html.heex:162 +#: lib/mv_web/live/member_live/index.html.heex:165 #: 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:128 +#: lib/mv_web/live/member_live/index.html.heex:131 #: lib/mv_web/live/member_live/show.ex:62 #, elixir-autogen, elixir-format msgid "Postal Code" @@ -158,7 +158,7 @@ msgstr "Postleitzahl" msgid "Save Member" msgstr "Mitglied speichern" -#: lib/mv_web/live/custom_field_live/form.ex:64 +#: lib/mv_web/live/custom_field_live/form.ex:66 #: lib/mv_web/live/custom_field_value_live/form.ex:74 #: lib/mv_web/live/member_live/form.ex:79 #: lib/mv_web/live/user_live/form.ex:124 @@ -167,7 +167,7 @@ msgid "Saving..." msgstr "Speichern..." #: lib/mv_web/live/member_live/form.ex:55 -#: lib/mv_web/live/member_live/index.html.heex:94 +#: lib/mv_web/live/member_live/index.html.heex:97 #: lib/mv_web/live/member_live/show.ex:60 #, elixir-autogen, elixir-format msgid "Street" @@ -183,6 +183,7 @@ msgstr "Dieses Formular dient zur Verwaltung von Mitgliedern und deren Eigenscha msgid "Id" msgstr "ID" +#: lib/mv_web/live/member_live/index/formatter.ex:65 #: lib/mv_web/live/member_live/show.ex:53 #, elixir-autogen, elixir-format msgid "No" @@ -198,19 +199,20 @@ 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/formatter.ex:64 #: lib/mv_web/live/member_live/show.ex:53 #, elixir-autogen, elixir-format msgid "Yes" msgstr "Ja" -#: lib/mv_web/live/custom_field_live/form.ex:108 +#: lib/mv_web/live/custom_field_live/form.ex:110 #: lib/mv_web/live/custom_field_value_live/form.ex:233 #: lib/mv_web/live/member_live/form.ex:138 #, elixir-autogen, elixir-format msgid "create" msgstr "erstellt" -#: lib/mv_web/live/custom_field_live/form.ex:109 +#: lib/mv_web/live/custom_field_live/form.ex:111 #: lib/mv_web/live/custom_field_value_live/form.ex:234 #: lib/mv_web/live/member_live/form.ex:139 #, elixir-autogen, elixir-format @@ -252,7 +254,7 @@ msgstr "Ihre E-Mail-Adresse wurde bestätigt" msgid "Your password has successfully been reset" msgstr "Ihr Passwort wurde erfolgreich zurückgesetzt" -#: lib/mv_web/live/custom_field_live/form.ex:67 +#: lib/mv_web/live/custom_field_live/form.ex:69 #: lib/mv_web/live/custom_field_live/index.ex:120 #: lib/mv_web/live/custom_field_value_live/form.ex:77 #: lib/mv_web/live/member_live/form.ex:82 @@ -266,7 +268,7 @@ msgstr "Abbrechen" msgid "Choose a member" msgstr "Mitglied auswählen" -#: lib/mv_web/live/custom_field_live/form.ex:60 +#: lib/mv_web/live/custom_field_live/form.ex:61 #, elixir-autogen, elixir-format msgid "Description" msgstr "Beschreibung" @@ -286,7 +288,7 @@ msgstr "Aktiviert" msgid "ID" msgstr "ID" -#: lib/mv_web/live/custom_field_live/form.ex:61 +#: lib/mv_web/live/custom_field_live/form.ex:62 #, elixir-autogen, elixir-format msgid "Immutable" msgstr "Unveränderlich" @@ -308,13 +310,13 @@ msgid "Member" msgstr "Mitglied" #: lib/mv_web/components/layouts/navbar.ex:19 -#: lib/mv_web/live/member_live/index.ex:39 +#: lib/mv_web/live/member_live/index.ex:57 #: lib/mv_web/live/member_live/index.html.heex:3 #, elixir-autogen, elixir-format msgid "Members" msgstr "Mitglieder" -#: lib/mv_web/live/custom_field_live/form.ex:50 +#: lib/mv_web/live/custom_field_live/form.ex:51 #, elixir-autogen, elixir-format msgid "Name" msgstr "Name" @@ -356,17 +358,17 @@ msgstr "Passwort-Authentifizierung" msgid "Profil" msgstr "Profil" -#: lib/mv_web/live/custom_field_live/form.ex:62 +#: lib/mv_web/live/custom_field_live/form.ex:63 #, elixir-autogen, elixir-format msgid "Required" msgstr "Erforderlich" -#: lib/mv_web/live/member_live/index.html.heex:34 +#: lib/mv_web/live/member_live/index.html.heex:37 #, elixir-autogen, elixir-format msgid "Select all members" msgstr "Alle Mitglieder auswählen" -#: lib/mv_web/live/member_live/index.html.heex:48 +#: lib/mv_web/live/member_live/index.html.heex:51 #, elixir-autogen, elixir-format msgid "Select member" msgstr "Mitglied auswählen" @@ -412,7 +414,7 @@ msgstr "Benutzer*in" msgid "Value" msgstr "Wert" -#: lib/mv_web/live/custom_field_live/form.ex:55 +#: lib/mv_web/live/custom_field_live/form.ex:56 #, elixir-autogen, elixir-format msgid "Value type" msgstr "Wertetyp" @@ -565,7 +567,7 @@ msgstr "Benutzer*innen" msgid "Click to sort" msgstr "Klicke um zu sortieren" -#: lib/mv_web/live/member_live/index.html.heex:60 +#: lib/mv_web/live/member_live/index.html.heex:63 #, elixir-autogen, elixir-format msgid "First name" msgstr "Vorname" @@ -617,7 +619,7 @@ msgstr "Benutzerdefinierte Feldwerte" msgid "Custom field" msgstr "Benutzerdefiniertes Feld" -#: lib/mv_web/live/custom_field_live/form.ex:115 +#: lib/mv_web/live/custom_field_live/form.ex:117 #, elixir-autogen, elixir-format msgid "Custom field %{action} successfully" msgstr "Benutzerdefiniertes Feld erfolgreich %{action}" @@ -632,7 +634,7 @@ msgstr "Benutzerdefinierter Feldwert erfolgreich %{action}" msgid "Please select a custom field first" msgstr "Bitte wähle zuerst ein Benutzerdefiniertes Feld" -#: lib/mv_web/live/custom_field_live/form.ex:65 +#: lib/mv_web/live/custom_field_live/form.ex:67 #, elixir-autogen, elixir-format msgid "Save Custom field" msgstr "Benutzerdefiniertes Feld speichern" @@ -642,7 +644,7 @@ msgstr "Benutzerdefiniertes Feld speichern" msgid "Save Custom field value" msgstr "Benutzerdefinierten Feldwert speichern" -#: lib/mv_web/live/custom_field_live/form.ex:45 +#: lib/mv_web/live/custom_field_live/form.ex:46 #, elixir-autogen, elixir-format msgid "Use this form to manage custom_field records in your database." msgstr "Verwende dieses Formular, um Benutzerdefinierte Felder in deiner Datenbank zu verwalten." @@ -694,6 +696,11 @@ msgstr "Obigen Text zur Bestätigung eingeben" msgid "To confirm deletion, please enter this text:" msgstr "Um die Löschung zu bestätigen, gib bitte folgenden Text ein:" +#: lib/mv_web/live/custom_field_live/form.ex:64 +#, elixir-autogen, elixir-format +msgid "Show in overview" +msgstr "In der Mitglieder-Übersicht anzeigen" + #~ #: lib/mv_web/live/custom_field_live/index.ex:97 #~ #, elixir-autogen, elixir-format #~ msgid "To confirm deletion, please enter the custom field slug:" diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot index 5942951..128b9bf 100644 --- a/priv/gettext/default.pot +++ b/priv/gettext/default.pot @@ -11,12 +11,12 @@ msgid "" msgstr "" -#: lib/mv_web/components/core_components.ex:339 +#: lib/mv_web/components/core_components.ex:356 #, elixir-autogen, elixir-format msgid "Actions" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex:200 +#: lib/mv_web/live/member_live/index.html.heex:202 #: lib/mv_web/live/user_live/index.html.heex:65 #, 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:145 +#: lib/mv_web/live/member_live/index.html.heex:148 #: 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:202 +#: lib/mv_web/live/member_live/index.html.heex:204 #: lib/mv_web/live/user_live/index.html.heex:67 #, elixir-autogen, elixir-format msgid "Delete" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex:194 +#: lib/mv_web/live/member_live/index.html.heex:196 #: lib/mv_web/live/user_live/form.ex:141 #: lib/mv_web/live/user_live/index.html.heex:59 #, 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:77 +#: lib/mv_web/live/member_live/index.html.heex:80 #: 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:179 +#: lib/mv_web/live/member_live/index.html.heex:182 #: 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:191 +#: lib/mv_web/live/member_live/index.html.heex:193 #: lib/mv_web/live/user_live/index.html.heex:56 #, elixir-autogen, elixir-format msgid "Show" @@ -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:111 +#: lib/mv_web/live/member_live/index.html.heex:114 #: lib/mv_web/live/member_live/show.ex:61 #, elixir-autogen, elixir-format msgid "House Number" @@ -141,14 +141,14 @@ msgid "Paid" msgstr "" #: lib/mv_web/live/member_live/form.ex:50 -#: lib/mv_web/live/member_live/index.html.heex:162 +#: lib/mv_web/live/member_live/index.html.heex:165 #: 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:128 +#: lib/mv_web/live/member_live/index.html.heex:131 #: lib/mv_web/live/member_live/show.ex:62 #, elixir-autogen, elixir-format msgid "Postal Code" @@ -159,7 +159,7 @@ msgstr "" msgid "Save Member" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:64 +#: lib/mv_web/live/custom_field_live/form.ex:66 #: lib/mv_web/live/custom_field_value_live/form.ex:74 #: lib/mv_web/live/member_live/form.ex:79 #: lib/mv_web/live/user_live/form.ex:124 @@ -168,7 +168,7 @@ msgid "Saving..." msgstr "" #: lib/mv_web/live/member_live/form.ex:55 -#: lib/mv_web/live/member_live/index.html.heex:94 +#: lib/mv_web/live/member_live/index.html.heex:97 #: lib/mv_web/live/member_live/show.ex:60 #, elixir-autogen, elixir-format msgid "Street" @@ -184,6 +184,7 @@ msgstr "" msgid "Id" msgstr "" +#: lib/mv_web/live/member_live/index/formatter.ex:65 #: lib/mv_web/live/member_live/show.ex:53 #, elixir-autogen, elixir-format msgid "No" @@ -199,19 +200,20 @@ msgstr "" msgid "This is a member record from your database." msgstr "" +#: lib/mv_web/live/member_live/index/formatter.ex:64 #: lib/mv_web/live/member_live/show.ex:53 #, elixir-autogen, elixir-format msgid "Yes" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:108 +#: lib/mv_web/live/custom_field_live/form.ex:110 #: lib/mv_web/live/custom_field_value_live/form.ex:233 #: lib/mv_web/live/member_live/form.ex:138 #, elixir-autogen, elixir-format msgid "create" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:109 +#: lib/mv_web/live/custom_field_live/form.ex:111 #: lib/mv_web/live/custom_field_value_live/form.ex:234 #: lib/mv_web/live/member_live/form.ex:139 #, elixir-autogen, elixir-format @@ -253,7 +255,7 @@ msgstr "" msgid "Your password has successfully been reset" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:67 +#: lib/mv_web/live/custom_field_live/form.ex:69 #: lib/mv_web/live/custom_field_live/index.ex:120 #: lib/mv_web/live/custom_field_value_live/form.ex:77 #: lib/mv_web/live/member_live/form.ex:82 @@ -267,7 +269,7 @@ msgstr "" msgid "Choose a member" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:60 +#: lib/mv_web/live/custom_field_live/form.ex:61 #, elixir-autogen, elixir-format msgid "Description" msgstr "" @@ -287,7 +289,7 @@ msgstr "" msgid "ID" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:61 +#: lib/mv_web/live/custom_field_live/form.ex:62 #, elixir-autogen, elixir-format msgid "Immutable" msgstr "" @@ -309,13 +311,13 @@ msgid "Member" msgstr "" #: lib/mv_web/components/layouts/navbar.ex:19 -#: lib/mv_web/live/member_live/index.ex:39 +#: lib/mv_web/live/member_live/index.ex:57 #: lib/mv_web/live/member_live/index.html.heex:3 #, elixir-autogen, elixir-format msgid "Members" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:50 +#: lib/mv_web/live/custom_field_live/form.ex:51 #, elixir-autogen, elixir-format msgid "Name" msgstr "" @@ -357,17 +359,17 @@ msgstr "" msgid "Profil" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:62 +#: lib/mv_web/live/custom_field_live/form.ex:63 #, elixir-autogen, elixir-format msgid "Required" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex:34 +#: lib/mv_web/live/member_live/index.html.heex:37 #, elixir-autogen, elixir-format msgid "Select all members" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex:48 +#: lib/mv_web/live/member_live/index.html.heex:51 #, elixir-autogen, elixir-format msgid "Select member" msgstr "" @@ -413,7 +415,7 @@ msgstr "" msgid "Value" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:55 +#: lib/mv_web/live/custom_field_live/form.ex:56 #, elixir-autogen, elixir-format msgid "Value type" msgstr "" @@ -566,7 +568,7 @@ msgstr "" msgid "Click to sort" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex:60 +#: lib/mv_web/live/member_live/index.html.heex:63 #, elixir-autogen, elixir-format msgid "First name" msgstr "" @@ -618,7 +620,7 @@ msgstr "" msgid "Custom field" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:115 +#: lib/mv_web/live/custom_field_live/form.ex:117 #, elixir-autogen, elixir-format msgid "Custom field %{action} successfully" msgstr "" @@ -633,7 +635,7 @@ msgstr "" msgid "Please select a custom field first" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:65 +#: lib/mv_web/live/custom_field_live/form.ex:67 #, elixir-autogen, elixir-format msgid "Save Custom field" msgstr "" @@ -643,7 +645,7 @@ msgstr "" msgid "Save Custom field value" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:45 +#: lib/mv_web/live/custom_field_live/form.ex:46 #, elixir-autogen, elixir-format msgid "Use this form to manage custom_field records in your database." msgstr "" @@ -694,3 +696,8 @@ msgstr "" #, elixir-autogen, elixir-format msgid "To confirm deletion, please enter this text:" msgstr "" + +#: lib/mv_web/live/custom_field_live/form.ex:64 +#, elixir-autogen, elixir-format +msgid "Show in overview" +msgstr "" diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po index 32a2d76..399b843 100644 --- a/priv/gettext/en/LC_MESSAGES/default.po +++ b/priv/gettext/en/LC_MESSAGES/default.po @@ -11,12 +11,12 @@ msgstr "" "Language: en\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" -#: lib/mv_web/components/core_components.ex:339 +#: lib/mv_web/components/core_components.ex:356 #, elixir-autogen, elixir-format msgid "Actions" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex:200 +#: lib/mv_web/live/member_live/index.html.heex:202 #: lib/mv_web/live/user_live/index.html.heex:65 #, 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:145 +#: lib/mv_web/live/member_live/index.html.heex:148 #: 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:202 +#: lib/mv_web/live/member_live/index.html.heex:204 #: lib/mv_web/live/user_live/index.html.heex:67 #, elixir-autogen, elixir-format msgid "Delete" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex:194 +#: lib/mv_web/live/member_live/index.html.heex:196 #: lib/mv_web/live/user_live/form.ex:141 #: lib/mv_web/live/user_live/index.html.heex:59 #, 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:77 +#: lib/mv_web/live/member_live/index.html.heex:80 #: 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:179 +#: lib/mv_web/live/member_live/index.html.heex:182 #: 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:191 +#: lib/mv_web/live/member_live/index.html.heex:193 #: lib/mv_web/live/user_live/index.html.heex:56 #, elixir-autogen, elixir-format msgid "Show" @@ -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:111 +#: lib/mv_web/live/member_live/index.html.heex:114 #: lib/mv_web/live/member_live/show.ex:61 #, elixir-autogen, elixir-format msgid "House Number" @@ -141,14 +141,14 @@ msgid "Paid" msgstr "" #: lib/mv_web/live/member_live/form.ex:50 -#: lib/mv_web/live/member_live/index.html.heex:162 +#: lib/mv_web/live/member_live/index.html.heex:165 #: 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:128 +#: lib/mv_web/live/member_live/index.html.heex:131 #: lib/mv_web/live/member_live/show.ex:62 #, elixir-autogen, elixir-format msgid "Postal Code" @@ -159,7 +159,7 @@ msgstr "" msgid "Save Member" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:64 +#: lib/mv_web/live/custom_field_live/form.ex:66 #: lib/mv_web/live/custom_field_value_live/form.ex:74 #: lib/mv_web/live/member_live/form.ex:79 #: lib/mv_web/live/user_live/form.ex:124 @@ -168,7 +168,7 @@ msgid "Saving..." msgstr "" #: lib/mv_web/live/member_live/form.ex:55 -#: lib/mv_web/live/member_live/index.html.heex:94 +#: lib/mv_web/live/member_live/index.html.heex:97 #: lib/mv_web/live/member_live/show.ex:60 #, elixir-autogen, elixir-format msgid "Street" @@ -184,6 +184,7 @@ msgstr "" msgid "Id" msgstr "" +#: lib/mv_web/live/member_live/index/formatter.ex:65 #: lib/mv_web/live/member_live/show.ex:53 #, elixir-autogen, elixir-format msgid "No" @@ -199,19 +200,20 @@ msgstr "" msgid "This is a member record from your database." msgstr "" +#: lib/mv_web/live/member_live/index/formatter.ex:64 #: lib/mv_web/live/member_live/show.ex:53 #, elixir-autogen, elixir-format msgid "Yes" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:108 +#: lib/mv_web/live/custom_field_live/form.ex:110 #: lib/mv_web/live/custom_field_value_live/form.ex:233 #: lib/mv_web/live/member_live/form.ex:138 #, elixir-autogen, elixir-format msgid "create" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:109 +#: lib/mv_web/live/custom_field_live/form.ex:111 #: lib/mv_web/live/custom_field_value_live/form.ex:234 #: lib/mv_web/live/member_live/form.ex:139 #, elixir-autogen, elixir-format @@ -253,7 +255,7 @@ msgstr "" msgid "Your password has successfully been reset" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:67 +#: lib/mv_web/live/custom_field_live/form.ex:69 #: lib/mv_web/live/custom_field_live/index.ex:120 #: lib/mv_web/live/custom_field_value_live/form.ex:77 #: lib/mv_web/live/member_live/form.ex:82 @@ -267,7 +269,7 @@ msgstr "" msgid "Choose a member" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:60 +#: lib/mv_web/live/custom_field_live/form.ex:61 #, elixir-autogen, elixir-format msgid "Description" msgstr "" @@ -287,7 +289,7 @@ msgstr "" msgid "ID" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:61 +#: lib/mv_web/live/custom_field_live/form.ex:62 #, elixir-autogen, elixir-format msgid "Immutable" msgstr "" @@ -309,13 +311,13 @@ msgid "Member" msgstr "" #: lib/mv_web/components/layouts/navbar.ex:19 -#: lib/mv_web/live/member_live/index.ex:39 +#: lib/mv_web/live/member_live/index.ex:57 #: lib/mv_web/live/member_live/index.html.heex:3 #, elixir-autogen, elixir-format msgid "Members" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:50 +#: lib/mv_web/live/custom_field_live/form.ex:51 #, elixir-autogen, elixir-format msgid "Name" msgstr "" @@ -357,17 +359,17 @@ msgstr "" msgid "Profil" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:62 +#: lib/mv_web/live/custom_field_live/form.ex:63 #, elixir-autogen, elixir-format msgid "Required" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex:34 +#: lib/mv_web/live/member_live/index.html.heex:37 #, elixir-autogen, elixir-format msgid "Select all members" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex:48 +#: lib/mv_web/live/member_live/index.html.heex:51 #, elixir-autogen, elixir-format msgid "Select member" msgstr "" @@ -413,7 +415,7 @@ msgstr "" msgid "Value" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:55 +#: lib/mv_web/live/custom_field_live/form.ex:56 #, elixir-autogen, elixir-format msgid "Value type" msgstr "" @@ -566,7 +568,7 @@ msgstr "" msgid "Click to sort" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex:60 +#: lib/mv_web/live/member_live/index.html.heex:63 #, elixir-autogen, elixir-format, fuzzy msgid "First name" msgstr "" @@ -618,7 +620,7 @@ msgstr "" msgid "Custom field" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:115 +#: lib/mv_web/live/custom_field_live/form.ex:117 #, elixir-autogen, elixir-format msgid "Custom field %{action} successfully" msgstr "" @@ -633,7 +635,7 @@ msgstr "" msgid "Please select a custom field first" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:65 +#: lib/mv_web/live/custom_field_live/form.ex:67 #, elixir-autogen, elixir-format msgid "Save Custom field" msgstr "" @@ -643,7 +645,7 @@ msgstr "" msgid "Save Custom field value" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:45 +#: lib/mv_web/live/custom_field_live/form.ex:46 #, elixir-autogen, elixir-format, fuzzy msgid "Use this form to manage custom_field records in your database." msgstr "" @@ -695,6 +697,11 @@ msgstr "" msgid "To confirm deletion, please enter this text:" msgstr "" +#: lib/mv_web/live/custom_field_live/form.ex:64 +#, elixir-autogen, elixir-format +msgid "Show in overview" +msgstr "" + #~ #: lib/mv_web/live/custom_field_live/index.ex:97 #~ #, elixir-autogen, elixir-format #~ msgid "To confirm deletion, please enter the custom field slug:" From 3da0ebcb3f6a86bfe3f667bdbfa56a966b9c7f5b Mon Sep 17 00:00:00 2001 From: Moritz Date: Thu, 27 Nov 2025 16:01:42 +0100 Subject: [PATCH 19/26] feat: Add keyboard navigation to member linking dropdown --- assets/js/app.js | 25 +++++++- docs/development-progress-log.md | 89 ++++++++++++++++++++++++++--- lib/mv_web/live/user_live/form.ex | 95 +++++++++++++++++++++++++++++-- notes.md | 58 +++++++++++++++++++ 4 files changed, 255 insertions(+), 12 deletions(-) create mode 100644 notes.md diff --git a/assets/js/app.js b/assets/js/app.js index 9b95296..e55a06d 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -24,9 +24,32 @@ import topbar from "../vendor/topbar" let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content") +// Hooks for LiveView components +let Hooks = {} + +// ComboBox hook: Prevents form submission when Enter is pressed in dropdown +Hooks.ComboBox = { + mounted() { + this.handleKeyDown = (e) => { + const isDropdownOpen = this.el.getAttribute("aria-expanded") === "true" + + if (e.key === "Enter" && isDropdownOpen) { + e.preventDefault() + } + } + + this.el.addEventListener("keydown", this.handleKeyDown) + }, + + destroyed() { + this.el.removeEventListener("keydown", this.handleKeyDown) + } +} + let liveSocket = new LiveSocket("/live", Socket, { longPollFallbackMs: 2500, - params: {_csrf_token: csrfToken} + params: {_csrf_token: csrfToken}, + hooks: Hooks }) // Listen for custom events from LiveView diff --git a/docs/development-progress-log.md b/docs/development-progress-log.md index 1b86106..51d0749 100644 --- a/docs/development-progress-log.md +++ b/docs/development-progress-log.md @@ -1328,9 +1328,10 @@ Implemented user-member linking functionality in User Edit/Create views with fuz **Key Features:** - Autocomplete dropdown with PostgreSQL Trigram fuzzy search +- Keyboard navigation (Arrow keys, Enter, Escape) - Link/unlink members to user accounts - Email synchronization between linked entities -- WCAG 2.1 AA compliant (ARIA labels) +- WCAG 2.1 AA compliant (ARIA labels, keyboard accessibility) - Bilingual UI (English/German) ### Technical Decisions @@ -1350,7 +1351,45 @@ window.addEventListener("phx:set-input-value", (e) => { ``` **Rationale:** LiveView DOM patching has race conditions with rapid state changes in autocomplete components. Direct DOM manipulation via `push_event` is the idiomatic LiveView solution for this edge case. -**3. Fuzzy Search Implementation** +**3. Keyboard Navigation: Hybrid Approach** +Implemented keyboard accessibility with **mostly Server-Side + minimal Client-Side**: + +```elixir +# Server-Side: Navigation and Selection (~45 lines) +def handle_event("member_dropdown_keydown", %{"key" => "ArrowDown"}, socket) do + # Focus management on server + new_index = min(current + 1, max_index) + {:noreply, assign(socket, focused_member_index: new_index)} +end +``` + +```javascript +// Client-Side: Only preventDefault for Enter in forms (~13 lines) +Hooks.ComboBox = { + mounted() { + this.el.addEventListener("keydown", (e) => { + const isDropdownOpen = this.el.getAttribute("aria-expanded") === "true" + if (e.key === "Enter" && isDropdownOpen) { + e.preventDefault() // Prevent form submission + } + }) + } +} +``` + +**Rationale:** +- Server-Side handles all navigation logic → simpler, testable, follows LiveView best practices +- Client-Side only prevents browser default behavior (form submit on Enter) +- Latency (~20-50ms) is imperceptible for keyboard events without DB queries +- Follows CODE_GUIDELINES "Minimal JavaScript Philosophy" + +**Alternative Considered:** Full Client-Side with JavaScript Hook (~80 lines) +- ❌ More complex code +- ❌ State synchronization between client/server +- ✅ Zero latency (but not noticeable in practice) +- **Decision:** Server-Side approach is simpler and sufficient + +**4. Fuzzy Search Implementation** Combined PostgreSQL Full-Text Search + Trigram for optimal results: ```sql -- FTS for exact word matching @@ -1393,11 +1432,13 @@ end - ✅ Direct DOM manipulation (autocomplete, input values) - ✅ Browser APIs (clipboard, geolocation) - ✅ Third-party libraries +- ✅ Preventing browser default behaviors (form submit, scroll) **When NOT to use JavaScript:** - ❌ Form submissions - ❌ Simple show/hide logic - ❌ Server-side data fetching +- ❌ Keyboard navigation logic (can be done server-side efficiently) **Pattern:** ```elixir @@ -1407,6 +1448,12 @@ socket |> push_event("event-name", %{key: value}) window.addEventListener("phx:event-name", (e) => { /* handle */ }) ``` +**Keyboard Events Pattern:** +For keyboard navigation in forms, use hybrid approach: +- Server handles navigation logic via `phx-window-keydown` +- Minimal hook only for `preventDefault()` to avoid form submit conflicts +- Result: ~13 lines JS vs ~80 lines for full client-side solution + #### 3. PostgreSQL Trigram Search Requires `pg_trgm` extension with GIN indexes: ```sql @@ -1418,7 +1465,34 @@ Supports: - Partial matching: "Mit" finds "Mitglied" - Substring: "exam" finds "example.com" -#### 4. Test-Driven Development for Bug Fixes +#### 4. Server-Side Keyboard Navigation Performance +**Challenge:** Concern that server-side keyboard events would feel laggy. + +**Reality Check:** +- LiveView roundtrip: ~20-50ms on decent connection +- Human perception threshold: ~100ms +- Result: **Feels instant** in practice + +**Why it works:** +```elixir +# Event handler only updates index (no DB queries) +def handle_event("member_dropdown_keydown", %{"key" => "ArrowDown"}, socket) do + new_index = min(socket.assigns.focused_member_index + 1, max_index) + {:noreply, assign(socket, focused_member_index: new_index)} +end +``` +- No database queries +- No complex computations +- Just state updates → extremely fast + +**When to use Client-Side instead:** +- Complex animations (Canvas, WebGL) +- Real-time gaming +- Continuous interactions (drag & drop, drawing) + +**Lesson:** Don't prematurely optimize for latency. Server-side is simpler and often sufficient. + +#### 5. Test-Driven Development for Bug Fixes Effective workflow: 1. Write test that reproduces bug (should fail) 2. Implement minimal fix @@ -1435,7 +1509,8 @@ Effective workflow: - `lib/mv_web/live/user_live/form.ex` - Event handlers, state management **Frontend:** -- `assets/js/app.js` - Input value hook (6 lines) +- `assets/js/app.js` - Input value hook (6 lines) + ComboBox hook (13 lines) +- `lib/mv_web/live/user_live/form.ex` - Keyboard event handlers, focus management - `priv/gettext/**/*.po` - 10 new translation keys (DE/EN) **Tests (NEW):** @@ -1472,14 +1547,14 @@ This project demonstrates a modern Phoenix application built with: **Next Steps:** - Implement roles & permissions - Add payment tracking -- Improve accessibility (WCAG 2.1 AA) +- ✅ ~~Improve accessibility (WCAG 2.1 AA)~~ - Keyboard navigation implemented - Member self-service portal - Email communication features --- -**Document Version:** 1.1 -**Last Updated:** 2025-11-13 +**Document Version:** 1.2 +**Last Updated:** 2025-11-27 **Maintainer:** Development Team **Status:** Living Document (update as project evolves) diff --git a/lib/mv_web/live/user_live/form.ex b/lib/mv_web/live/user_live/form.ex index b8a0294..9619a15 100644 --- a/lib/mv_web/live/user_live/form.ex +++ b/lib/mv_web/live/user_live/form.ex @@ -162,9 +162,11 @@ defmodule MvWeb.UserLive.Form do type="text" id="member-search-input" role="combobox" + phx-hook="ComboBox" phx-focus="show_member_dropdown" phx-change="search_members" phx-debounce="300" + phx-window-keydown="member_dropdown_keydown" value={@member_search_query} placeholder={gettext("Search for a member to link...")} class="w-full input" @@ -175,6 +177,11 @@ defmodule MvWeb.UserLive.Form do aria-autocomplete="list" aria-controls="member-dropdown" aria-expanded={to_string(@show_member_dropdown)} + aria-activedescendant={ + if @focused_member_index, + do: "member-option-#{@focused_member_index}", + else: nil + } autocomplete="off" /> @@ -186,15 +193,22 @@ defmodule MvWeb.UserLive.Form do class={"absolute z-10 w-full mt-1 bg-base-100 border border-base-300 rounded-lg shadow-lg max-h-60 overflow-auto #{if !@show_member_dropdown, do: "hidden"}"} phx-click-away="hide_member_dropdown" > - <%= for member <- @available_members do %> + <%= for {member, index} <- Enum.with_index(@available_members) do %>

{member.first_name} {member.last_name}

{member.email}

@@ -263,6 +277,7 @@ defmodule MvWeb.UserLive.Form do |> assign(:selected_member_id, nil) |> assign(:selected_member_name, nil) |> assign(:unlink_member, false) + |> assign(:focused_member_index, nil) |> load_initial_members() |> assign_form()} end @@ -353,7 +368,55 @@ defmodule MvWeb.UserLive.Form do end def handle_event("hide_member_dropdown", _params, socket) do - {:noreply, assign(socket, show_member_dropdown: false)} + {:noreply, assign(socket, show_member_dropdown: false, focused_member_index: nil)} + end + + def handle_event("member_dropdown_keydown", %{"key" => "ArrowDown"}, socket) do + return_if_dropdown_closed(socket, fn -> + max_index = length(socket.assigns.available_members) - 1 + current = socket.assigns.focused_member_index + + new_index = + case current do + nil -> 0 + index when index < max_index -> index + 1 + _ -> current + end + + {:noreply, assign(socket, focused_member_index: new_index)} + end) + end + + def handle_event("member_dropdown_keydown", %{"key" => "ArrowUp"}, socket) do + return_if_dropdown_closed(socket, fn -> + current = socket.assigns.focused_member_index + + new_index = + case current do + nil -> 0 + 0 -> 0 + index -> index - 1 + end + + {:noreply, assign(socket, focused_member_index: new_index)} + end) + end + + def handle_event("member_dropdown_keydown", %{"key" => "Enter"}, socket) do + return_if_dropdown_closed(socket, fn -> + select_focused_member(socket) + end) + end + + def handle_event("member_dropdown_keydown", %{"key" => "Escape"}, socket) do + return_if_dropdown_closed(socket, fn -> + {:noreply, assign(socket, show_member_dropdown: false, focused_member_index: nil)} + end) + end + + def handle_event("member_dropdown_keydown", _params, socket) do + # Ignore other keys + {:noreply, socket} end def handle_event("search_members", %{"member_search" => query}, socket) do @@ -362,6 +425,7 @@ defmodule MvWeb.UserLive.Form do |> assign(:member_search_query, query) |> load_available_members(query) |> assign(:show_member_dropdown, true) + |> assign(:focused_member_index, nil) {:noreply, socket} end @@ -406,6 +470,29 @@ defmodule MvWeb.UserLive.Form do @spec notify_parent(any()) :: any() defp notify_parent(msg), do: send(self(), {__MODULE__, msg}) + # Helper to ignore keyboard events when dropdown is closed + @spec return_if_dropdown_closed(Phoenix.LiveView.Socket.t(), function()) :: + {:noreply, Phoenix.LiveView.Socket.t()} + defp return_if_dropdown_closed(socket, func) do + if socket.assigns.show_member_dropdown do + func.() + else + {:noreply, socket} + end + end + + # Select the currently focused member from the dropdown + @spec select_focused_member(Phoenix.LiveView.Socket.t()) :: + {:noreply, Phoenix.LiveView.Socket.t()} + defp select_focused_member(socket) do + with index when not is_nil(index) <- socket.assigns.focused_member_index, + member when not is_nil(member) <- Enum.at(socket.assigns.available_members, index) do + handle_event("select_member", %{"id" => member.id}, socket) + else + _ -> {:noreply, socket} + end + end + @spec assign_form(Phoenix.LiveView.Socket.t()) :: Phoenix.LiveView.Socket.t() defp assign_form(%{assigns: %{user: user, show_password_fields: show_password_fields}} = socket) do form = diff --git a/notes.md b/notes.md new file mode 100644 index 0000000..a5aa44f --- /dev/null +++ b/notes.md @@ -0,0 +1,58 @@ +# User-Member Association - Test Status + +## Test Files Created/Modified + +### 1. test/membership/member_available_for_linking_test.exs (NEU) +**Status**: Alle Tests sollten FEHLSCHLAGEN ❌ +**Grund**: Die `:available_for_linking` Action existiert noch nicht + +Tests: +- ✗ returns only unlinked members and limits to 10 +- ✗ limits results to 10 members even when more exist +- ✗ email match: returns only member with matching email when exists +- ✗ email match: returns all unlinked members when no email match +- ✗ search query: filters by first_name, last_name, and email +- ✗ email match takes precedence over search query + +### 2. test/accounts/user_member_linking_test.exs (NEU) +**Status**: Tests sollten teilweise ERFOLGREICH sein ✅ / teilweise FEHLSCHLAGEN ❌ + +Tests: +- ✓ link user to member with different email syncs member email (sollte BESTEHEN - Email-Sync ist implementiert) +- ✓ unlink member from user sets member to nil (sollte BESTEHEN - Unlink ist implementiert) +- ✓ cannot link member already linked to another user (sollte BESTEHEN - Validierung existiert) +- ✓ cannot change member link directly, must unlink first (sollte BESTEHEN - Validierung existiert) + +### 3. test/mv_web/user_live/form_test.exs (ERWEITERT) +**Status**: Alle neuen Tests sollten FEHLSCHLAGEN ❌ +**Grund**: Member-Linking UI ist noch nicht implementiert + +Neue Tests: +- ✗ shows linked member with unlink button when user has member +- ✗ shows member search field when user has no member +- ✗ selecting member and saving links member to user +- ✗ unlinking member and saving removes member from user + +### 4. test/mv_web/user_live/index_test.exs (ERWEITERT) +**Status**: Neuer Test sollte FEHLSCHLAGEN ❌ +**Grund**: Member-Spalte wird noch nicht in der Index-View angezeigt + +Neuer Test: +- ✗ displays linked member name in user list + +## Zusammenfassung + +**Tests gesamt**: 13 +**Sollten BESTEHEN**: 4 (Backend-Validierungen bereits vorhanden) +**Sollten FEHLSCHLAGEN**: 9 (Features noch nicht implementiert) + +## Nächste Schritte + +1. Implementiere `:available_for_linking` Action in `lib/membership/member.ex` +2. Erstelle `MemberAutocompleteComponent` in `lib/mv_web/live/components/member_autocomplete_component.ex` +3. Integriere Member-Linking UI in `lib/mv_web/live/user_live/form.ex` +4. Füge Member-Spalte zu `lib/mv_web/live/user_live/index.ex` hinzu +5. Füge Gettext-Übersetzungen hinzu + +Nach jeder Implementierung: Tests erneut ausführen und prüfen, ob sie grün werden. + From b509dc4ea37134c6664a4ecc1d713a0a85e87d6d Mon Sep 17 00:00:00 2001 From: carla Date: Wed, 19 Nov 2025 17:27:26 +0100 Subject: [PATCH 20/26] chore: add migration for show in overview flag --- ..._add_show_in_overview_to_custom_fields.exs | 21 ++++ .../repo/custom_fields/20251119160509.json | 118 ++++++++++++++++++ 2 files changed, 139 insertions(+) create mode 100644 priv/repo/migrations/20251119160509_add_show_in_overview_to_custom_fields.exs create mode 100644 priv/resource_snapshots/repo/custom_fields/20251119160509.json diff --git a/priv/repo/migrations/20251119160509_add_show_in_overview_to_custom_fields.exs b/priv/repo/migrations/20251119160509_add_show_in_overview_to_custom_fields.exs new file mode 100644 index 0000000..32b4801 --- /dev/null +++ b/priv/repo/migrations/20251119160509_add_show_in_overview_to_custom_fields.exs @@ -0,0 +1,21 @@ +defmodule Mv.Repo.Migrations.AddShowInOverviewToCustomFields 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(:custom_fields) do + add :show_in_overview, :boolean, null: false, default: true + end + end + + def down do + alter table(:custom_fields) do + remove :show_in_overview + end + end +end diff --git a/priv/resource_snapshots/repo/custom_fields/20251119160509.json b/priv/resource_snapshots/repo/custom_fields/20251119160509.json new file mode 100644 index 0000000..718fe51 --- /dev/null +++ b/priv/resource_snapshots/repo/custom_fields/20251119160509.json @@ -0,0 +1,118 @@ +{ + "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": "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": "false", + "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": "9FBFC42DA896058F88DEDAE774614919222BF2EF2F8CB27386D02C2CE67F03DE", + "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 + } + ], + "multitenancy": { + "attribute": null, + "global": null, + "strategy": null + }, + "repo": "Elixir.Mv.Repo", + "schema": null, + "table": "custom_fields" +} \ No newline at end of file From 4313703538315041f7e8a4612eada1d1b0c581e1 Mon Sep 17 00:00:00 2001 From: carla Date: Wed, 26 Nov 2025 18:14:29 +0100 Subject: [PATCH 21/26] test: added tests --- .../custom_field_show_in_overview_test.exs | 77 +++ ...index_custom_fields_accessibility_test.exs | 109 +++++ .../index_custom_fields_display_test.exs | 262 ++++++++++ .../index_custom_fields_edge_cases_test.exs | 174 +++++++ .../index_custom_fields_sorting_test.exs | 446 ++++++++++++++++++ 5 files changed, 1068 insertions(+) create mode 100644 test/membership/custom_field_show_in_overview_test.exs create mode 100644 test/mv_web/member_live/index_custom_fields_accessibility_test.exs create mode 100644 test/mv_web/member_live/index_custom_fields_display_test.exs create mode 100644 test/mv_web/member_live/index_custom_fields_edge_cases_test.exs create mode 100644 test/mv_web/member_live/index_custom_fields_sorting_test.exs diff --git a/test/membership/custom_field_show_in_overview_test.exs b/test/membership/custom_field_show_in_overview_test.exs new file mode 100644 index 0000000..adac600 --- /dev/null +++ b/test/membership/custom_field_show_in_overview_test.exs @@ -0,0 +1,77 @@ +defmodule Mv.Membership.CustomFieldShowInOverviewTest do + @moduledoc """ + Tests for CustomField show_in_overview attribute. + + Tests cover: + - Creating custom fields with show_in_overview: true + - Creating custom fields with show_in_overview: false (default) + - Updating show_in_overview to true + - Updating show_in_overview to false + """ + use Mv.DataCase, async: true + + alias Mv.Membership.CustomField + + describe "show_in_overview attribute" do + test "creates custom field with show_in_overview: true" do + assert {:ok, custom_field} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "test_field_show", + value_type: :string, + show_in_overview: true + }) + |> Ash.create() + + assert custom_field.show_in_overview == true + end + + test "creates custom field with show_in_overview: true (default)" do + assert {:ok, custom_field} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "test_field_hide", + value_type: :string + }) + |> Ash.create() + + assert custom_field.show_in_overview == true + end + + test "updates show_in_overview to true" do + {:ok, custom_field} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "test_field_update", + value_type: :string, + show_in_overview: false + }) + |> Ash.create() + + assert {:ok, updated_field} = + custom_field + |> Ash.Changeset.for_update(:update, %{show_in_overview: true}) + |> Ash.update() + + assert updated_field.show_in_overview == true + end + + test "updates show_in_overview to false" do + {:ok, custom_field} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "test_field_update2", + value_type: :string, + show_in_overview: true + }) + |> Ash.create() + + assert {:ok, updated_field} = + custom_field + |> Ash.Changeset.for_update(:update, %{show_in_overview: false}) + |> Ash.update() + + assert updated_field.show_in_overview == false + end + end +end diff --git a/test/mv_web/member_live/index_custom_fields_accessibility_test.exs b/test/mv_web/member_live/index_custom_fields_accessibility_test.exs new file mode 100644 index 0000000..e4d174f --- /dev/null +++ b/test/mv_web/member_live/index_custom_fields_accessibility_test.exs @@ -0,0 +1,109 @@ +defmodule MvWeb.MemberLive.IndexCustomFieldsAccessibilityTest do + @moduledoc """ + Accessibility tests for custom field columns in the member overview. + + Tests cover: + - SortHeaderComponent for custom fields has correct ARIA labels + - Tab navigation works for custom field columns + - Screen reader announcements for sorting + """ + use MvWeb.ConnCase, async: true + import Phoenix.LiveViewTest + require Ash.Query + + alias Mv.Membership.{CustomField, CustomFieldValue, Member} + + setup do + # Create test member + {:ok, member} = + Member + |> Ash.Changeset.for_create(:create_member, %{ + first_name: "Alice", + last_name: "Anderson", + email: "alice@example.com" + }) + |> Ash.create() + + # Create custom field with show_in_overview: true + {:ok, field} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "membership_number", + value_type: :string, + show_in_overview: true + }) + |> Ash.create() + + # Create custom field value + {:ok, _cfv} = + CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member.id, + custom_field_id: field.id, + value: %{"_union_type" => "string", "_union_value" => "A001"} + }) + |> Ash.create() + + %{member: member, field: field} + end + + test "sort header component for custom fields has correct ARIA labels", %{ + conn: conn, + field: field + } do + conn = conn_with_oidc_user(conn) + {:ok, _view, html} = live(conn, "/members") + + # Check that the sort button has aria-label + assert html =~ ~r/aria-label=["']Click to sort["']/i or + html =~ ~r/aria-label=["'].*sort.*["']/i + + # Check that data-testid is present for testing + assert html =~ ~r/data-testid=["']custom_field_#{field.id}["']/ + end + + test "sort header component shows correct ARIA label when sorted ascending", %{ + conn: conn, + field: field + } do + conn = conn_with_oidc_user(conn) + {:ok, view, _html} = live(conn, "/members?query=&sort_field=custom_field_#{field.id}&sort_order=asc") + + html = render(view) + + # Check that aria-label indicates ascending sort + assert html =~ ~r/aria-label=["'].*ascending.*["']/i + end + + test "sort header component shows correct ARIA label when sorted descending", %{ + conn: conn, + field: field + } do + conn = conn_with_oidc_user(conn) + {:ok, view, _html} = live(conn, "/members?query=&sort_field=custom_field_#{field.id}&sort_order=desc") + + html = render(view) + + # Check that aria-label indicates descending sort + assert html =~ ~r/aria-label=["'].*descending.*["']/i + end + + test "custom field column header is keyboard accessible", %{conn: conn, field: field} do + conn = conn_with_oidc_user(conn) + {:ok, _view, html} = live(conn, "/members") + + # Check that the sort button is a button element (keyboard accessible) + assert html =~ ~r/]*data-testid=["']custom_field_#{field.id}["']/ + + # Button should not have tabindex="-1" (which would remove from tab order) + refute html =~ ~r/tabindex=["']-1["']/ + end + + test "custom field column header has proper semantic structure", %{conn: conn, field: field} do + conn = conn_with_oidc_user(conn) + {:ok, _view, html} = live(conn, "/members") + + # Check that custom field name is displayed in the header + assert html =~ field.name + end +end diff --git a/test/mv_web/member_live/index_custom_fields_display_test.exs b/test/mv_web/member_live/index_custom_fields_display_test.exs new file mode 100644 index 0000000..7788c60 --- /dev/null +++ b/test/mv_web/member_live/index_custom_fields_display_test.exs @@ -0,0 +1,262 @@ +defmodule MvWeb.MemberLive.IndexCustomFieldsDisplayTest do + @moduledoc """ + Tests for displaying custom fields in the member overview. + + Tests cover: + - Custom fields with show_in_overview: true are displayed + - Custom fields with show_in_overview: false are not displayed + - Multiple custom fields with show_in_overview: true are all displayed + - Custom field values are correctly formatted for different types + - Members without custom field values show empty cell or "-" + """ + 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" + }) + |> Ash.create() + + {:ok, member2} = + Member + |> Ash.Changeset.for_create(:create_member, %{ + first_name: "Bob", + last_name: "Brown", + email: "bob@example.com" + }) + |> Ash.create() + + # Create custom fields + {:ok, field_show_string} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "phone_mobile", + value_type: :string, + show_in_overview: true + }) + |> Ash.create() + + {:ok, field_hide} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "internal_note", + value_type: :string, + show_in_overview: false + }) + |> Ash.create() + + {:ok, field_show_integer} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "membership_number", + value_type: :integer, + show_in_overview: true + }) + |> Ash.create() + + {:ok, field_show_boolean} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "newsletter", + value_type: :boolean, + show_in_overview: true + }) + |> Ash.create() + + {:ok, field_show_date} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "birthday", + value_type: :date, + show_in_overview: true + }) + |> Ash.create() + + {:ok, field_show_email} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "secondary_email", + value_type: :email, + show_in_overview: true + }) + |> Ash.create() + + # Create custom field values for member1 + {:ok, _cfv1} = + CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member1.id, + custom_field_id: field_show_string.id, + value: %{"_union_type" => "string", "_union_value" => "+49123456789"} + }) + |> Ash.create() + + {:ok, _cfv2} = + CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member1.id, + custom_field_id: field_show_integer.id, + value: %{"_union_type" => "integer", "_union_value" => 12345} + }) + |> Ash.create() + + {:ok, _cfv3} = + CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member1.id, + custom_field_id: field_show_boolean.id, + value: %{"_union_type" => "boolean", "_union_value" => true} + }) + |> Ash.create() + + {:ok, _cfv4} = + CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member1.id, + custom_field_id: field_show_date.id, + value: %{"_union_type" => "date", "_union_value" => ~D[1990-05-15]} + }) + |> Ash.create() + + {:ok, _cfv5} = + CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member1.id, + custom_field_id: field_show_email.id, + value: %{"_union_type" => "email", "_union_value" => "alice.private@example.com"} + }) + |> Ash.create() + + # Create hidden custom field value (should not be displayed) + {:ok, _cfv_hidden} = + CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member1.id, + custom_field_id: field_hide.id, + value: %{"_union_type" => "string", "_union_value" => "Internal note"} + }) + |> Ash.create() + + %{ + member1: member1, + member2: member2, + field_show_string: field_show_string, + field_hide: field_hide, + field_show_integer: field_show_integer, + field_show_boolean: field_show_boolean, + field_show_date: field_show_date, + field_show_email: field_show_email + } + end + + test "displays custom field with show_in_overview: true", %{ + conn: conn, + member1: _member1, + field_show_string: field + } do + conn = conn_with_oidc_user(conn) + {:ok, _view, html} = live(conn, "/members") + + # Check that the custom field column header is displayed + assert html =~ field.name + + # Check that the value is displayed + assert html =~ "+49123456789" + end + + test "does not display custom field with show_in_overview: false", %{ + conn: conn, + member1: _member1, + field_hide: field + } do + conn = conn_with_oidc_user(conn) + {:ok, _view, html} = live(conn, "/members") + + # Check that the hidden custom field column header is NOT displayed + refute html =~ field.name + + # Check that the value is NOT displayed + refute html =~ "Internal note" + end + + test "displays multiple custom fields with show_in_overview: true", %{ + conn: conn, + field_show_string: field_string, + field_show_integer: field_integer, + field_show_boolean: field_boolean + } do + conn = conn_with_oidc_user(conn) + {:ok, _view, html} = live(conn, "/members") + + # Check that all visible custom field column headers are displayed + assert html =~ field_string.name + assert html =~ field_integer.name + assert html =~ field_boolean.name + end + + test "formats string custom field values correctly", %{conn: conn, member1: _member1} do + conn = conn_with_oidc_user(conn) + {:ok, _view, html} = live(conn, "/members") + + assert html =~ "+49123456789" + end + + test "formats integer custom field values correctly", %{conn: conn, member1: _member1} do + conn = conn_with_oidc_user(conn) + {:ok, _view, html} = live(conn, "/members") + + assert html =~ "12345" + end + + test "formats boolean custom field values correctly", %{conn: conn, member1: _member1} do + conn = conn_with_oidc_user(conn) + {:ok, _view, html} = live(conn, "/members") + + # Boolean should be displayed as "Yes" or "No" or similar + # Check for true representation + assert html =~ "true" or html =~ "Yes" or html =~ "Ja" + end + + test "formats date custom field values correctly", %{conn: conn, member1: _member1} 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" + end + + test "formats email custom field values correctly", %{conn: conn, member1: _member1} do + conn = conn_with_oidc_user(conn) + {:ok, _view, html} = live(conn, "/members") + + assert html =~ "alice.private@example.com" + end + + test "shows empty cell or placeholder for members without custom field values", %{ + conn: conn, + member2: _member2, + field_show_string: field + } do + conn = conn_with_oidc_user(conn) + {:ok, _view, html} = live(conn, "/members") + + # 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 "-" + assert html =~ "+49123456789" + end +end diff --git a/test/mv_web/member_live/index_custom_fields_edge_cases_test.exs b/test/mv_web/member_live/index_custom_fields_edge_cases_test.exs new file mode 100644 index 0000000..9d44c40 --- /dev/null +++ b/test/mv_web/member_live/index_custom_fields_edge_cases_test.exs @@ -0,0 +1,174 @@ +defmodule MvWeb.MemberLive.IndexCustomFieldsEdgeCasesTest do + @moduledoc """ + Edge case tests for custom fields in the member overview. + + Tests cover: + - Custom field without values (all members have no value) + - Very long custom field values are correctly displayed + """ + use MvWeb.ConnCase, async: true + import Phoenix.LiveViewTest + require Ash.Query + + alias Mv.Membership.{CustomField, Member} + + test "displays custom field column even when no members have values", %{conn: conn} do + # Create test members without custom field values + {:ok, _member1} = + Member + |> Ash.Changeset.for_create(:create_member, %{ + first_name: "Alice", + last_name: "Anderson", + email: "alice@example.com" + }) + |> Ash.create() + + {:ok, _member2} = + Member + |> Ash.Changeset.for_create(:create_member, %{ + first_name: "Bob", + last_name: "Brown", + email: "bob@example.com" + }) + |> Ash.create() + + # Create custom field with show_in_overview: true but no values + {:ok, field} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "membership_number", + value_type: :string, + show_in_overview: true + }) + |> Ash.create() + + conn = conn_with_oidc_user(conn) + {:ok, _view, html} = live(conn, "/members") + + # Check that the custom field column header is still displayed + assert html =~ field.name + end + + test "displays very long custom field values correctly", %{conn: conn} do + # Create test member + {:ok, member} = + Member + |> Ash.Changeset.for_create(:create_member, %{ + first_name: "Alice", + last_name: "Anderson", + email: "alice@example.com" + }) + |> Ash.create() + + # Create custom field + {:ok, field} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "long_note", + value_type: :string, + show_in_overview: true + }) + |> Ash.create() + + # Create very long value (but within limits) + long_value = String.duplicate("A", 500) + + {:ok, _cfv} = + Mv.Membership.CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member.id, + custom_field_id: field.id, + value: %{"_union_type" => "string", "_union_value" => long_value} + }) + |> Ash.create() + + conn = conn_with_oidc_user(conn) + {:ok, _view, html} = live(conn, "/members") + + # Check that the value is displayed (may be truncated in UI, but should be present) + # We check for at least part of the value + assert html =~ "A" or html =~ long_value + end + + test "handles multiple custom fields with show_in_overview correctly", %{conn: conn} do + # Create test member + {:ok, member} = + Member + |> Ash.Changeset.for_create(:create_member, %{ + first_name: "Alice", + last_name: "Anderson", + email: "alice@example.com" + }) + |> Ash.create() + + # Create multiple custom fields with show_in_overview: true + {:ok, field1} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "field1", + value_type: :string, + show_in_overview: true + }) + |> Ash.create() + + {:ok, field2} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "field2", + value_type: :string, + show_in_overview: true + }) + |> Ash.create() + + {:ok, field3} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "field3", + value_type: :string, + show_in_overview: true + }) + |> Ash.create() + + # Create values for all fields + {:ok, _cfv1} = + Mv.Membership.CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member.id, + custom_field_id: field1.id, + value: %{"_union_type" => "string", "_union_value" => "Value1"} + }) + |> Ash.create() + + {:ok, _cfv2} = + Mv.Membership.CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member.id, + custom_field_id: field2.id, + value: %{"_union_type" => "string", "_union_value" => "Value2"} + }) + |> Ash.create() + + {:ok, _cfv3} = + Mv.Membership.CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member.id, + custom_field_id: field3.id, + value: %{"_union_type" => "string", "_union_value" => "Value3"} + }) + |> Ash.create() + + conn = conn_with_oidc_user(conn) + {:ok, _view, html} = live(conn, "/members") + + # Check that all custom field columns are displayed + assert html =~ field1.name + assert html =~ field2.name + assert html =~ field3.name + + # Check that all values are displayed + assert html =~ "Value1" + assert html =~ "Value2" + assert html =~ "Value3" + end +end + diff --git a/test/mv_web/member_live/index_custom_fields_sorting_test.exs b/test/mv_web/member_live/index_custom_fields_sorting_test.exs new file mode 100644 index 0000000..e1c99b2 --- /dev/null +++ b/test/mv_web/member_live/index_custom_fields_sorting_test.exs @@ -0,0 +1,446 @@ +defmodule MvWeb.MemberLive.IndexCustomFieldsSortingTest do + @moduledoc """ + Tests for sorting by custom fields in the member overview. + + Tests cover: + - Sorting by custom field (ascending) + - Sorting by custom field (descending) + - Sorting by custom field works with search + - Sorting by custom field works with URL parameters + - Sorting by custom field works with other columns + """ + 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" + }) + |> Ash.create() + + {:ok, member2} = + Member + |> Ash.Changeset.for_create(:create_member, %{ + first_name: "Bob", + last_name: "Brown", + email: "bob@example.com" + }) + |> Ash.create() + + {:ok, member3} = + Member + |> Ash.Changeset.for_create(:create_member, %{ + first_name: "Charlie", + last_name: "Clark", + email: "charlie@example.com" + }) + |> Ash.create() + + # Create custom field with show_in_overview: true + {:ok, field_string} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "membership_number", + value_type: :string, + show_in_overview: true + }) + |> Ash.create() + + {:ok, field_integer} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "priority", + value_type: :integer, + 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: field_string.id, + value: %{"_union_type" => "string", "_union_value" => "A001"} + }) + |> Ash.create() + + {:ok, _cfv2} = + CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member2.id, + custom_field_id: field_string.id, + value: %{"_union_type" => "string", "_union_value" => "C003"} + }) + |> Ash.create() + + {:ok, _cfv3} = + CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member3.id, + custom_field_id: field_string.id, + value: %{"_union_type" => "string", "_union_value" => "B002"} + }) + |> Ash.create() + + {:ok, _cfv4} = + CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member1.id, + custom_field_id: field_integer.id, + value: %{"_union_type" => "integer", "_union_value" => 10} + }) + |> Ash.create() + + {:ok, _cfv5} = + CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member2.id, + custom_field_id: field_integer.id, + value: %{"_union_type" => "integer", "_union_value" => 30} + }) + |> Ash.create() + + {:ok, _cfv6} = + CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member3.id, + custom_field_id: field_integer.id, + value: %{"_union_type" => "integer", "_union_value" => 20} + }) + |> Ash.create() + + %{ + member1: member1, + member2: member2, + member3: member3, + field_string: field_string, + field_integer: field_integer + } + end + + test "sorts by custom field ascending", %{conn: conn, field_string: field} do + conn = conn_with_oidc_user(conn) + {:ok, view, _html} = live(conn, "/members") + + # Click on custom field column header to sort + view + |> element("[data-testid='custom_field_#{field.id}']") + |> render_click() + + # Check URL was updated + assert_patch(view, "/members?query=&sort_field=custom_field_#{field.id}&sort_order=asc") + + # Verify sort state + assert has_element?(view, "[data-testid='custom_field_#{field.id}'][aria-label='ascending']") + end + + test "sorts by custom field descending", %{conn: conn, field_string: field} do + conn = conn_with_oidc_user(conn) + {:ok, view, _html} = live(conn, "/members?sort_field=custom_field_#{field.id}&sort_order=asc") + + # Click again to toggle to descending + view + |> element("[data-testid='custom_field_#{field.id}']") + |> render_click() + + # Check URL was updated + assert_patch(view, "/members?query=&sort_field=custom_field_#{field.id}&sort_order=desc") + + # Verify sort state + assert has_element?(view, "[data-testid='custom_field_#{field.id}'][aria-label='descending']") + end + + test "sorting by custom field works with search", %{conn: conn, field_string: field} do + conn = conn_with_oidc_user(conn) + {:ok, view, _html} = live(conn, "/members?query=Alice") + + # Click on custom field column header to sort + view + |> element("[data-testid='custom_field_#{field.id}']") + |> render_click() + + # Check URL maintains search query + assert_patch(view, "/members?query=Alice&sort_field=custom_field_#{field.id}&sort_order=asc") + end + + test "sorting by custom field works with URL parameters", %{conn: conn, field_string: field} do + conn = conn_with_oidc_user(conn) + {:ok, view, _html} = live(conn, "/members?query=&sort_field=custom_field_#{field.id}&sort_order=desc") + + # Check that the sort state is correctly applied + assert has_element?(view, "[data-testid='custom_field_#{field.id}'][aria-label='descending']") + end + + test "clicking different custom field column resets order to ascending", %{ + conn: conn, + field_string: field_string, + field_integer: field_integer + } do + conn = conn_with_oidc_user(conn) + {:ok, view, _html} = live(conn, "/members?query=&sort_field=custom_field_#{field_string.id}&sort_order=desc") + + # Click on a different custom field column + view + |> element("[data-testid='custom_field_#{field_integer.id}']") + |> render_click() + + assert_patch(view, "/members?query=&sort_field=custom_field_#{field_integer.id}&sort_order=asc") + end + + test "clicking regular column after custom field column works", %{ + conn: conn, + field_string: field + } do + conn = conn_with_oidc_user(conn) + {:ok, view, _html} = live(conn, "/members?query=&sort_field=custom_field_#{field.id}&sort_order=desc") + + # Click on email column + view + |> element("[data-testid='email']") + |> render_click() + + assert_patch(view, "/members?query=&sort_field=email&sort_order=asc") + end + + test "clicking custom field column after regular column works", %{ + conn: conn, + field_string: field + } do + conn = conn_with_oidc_user(conn) + {:ok, view, _html} = live(conn, "/members?query=&sort_field=email&sort_order=desc") + + # Click on custom field column + view + |> element("[data-testid='custom_field_#{field.id}']") + |> render_click() + + assert_patch(view, "/members?query=&sort_field=custom_field_#{field.id}&sort_order=asc") + end + + test "NULL values and empty strings are always sorted last (ASC)", %{conn: conn} do + # Create additional members with NULL and empty string values + {:ok, member_with_value} = + Member + |> Ash.Changeset.for_create(:create_member, %{ + first_name: "WithValue", + last_name: "Test", + email: "withvalue@example.com" + }) + |> Ash.create() + + {:ok, member_with_empty} = + Member + |> Ash.Changeset.for_create(:create_member, %{ + first_name: "WithEmpty", + last_name: "Test", + email: "withempty@example.com" + }) + |> Ash.create() + + {:ok, member_with_null} = + Member + |> Ash.Changeset.for_create(:create_member, %{ + first_name: "WithNull", + last_name: "Test", + email: "withnull@example.com" + }) + |> Ash.create() + + {:ok, member_with_another_value} = + Member + |> Ash.Changeset.for_create(:create_member, %{ + first_name: "AnotherValue", + last_name: "Test", + email: "another@example.com" + }) + |> Ash.create() + + # Create custom field + {:ok, field} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "test_field", + value_type: :string, + show_in_overview: true + }) + |> Ash.create() + + # Create values: one with actual value, one with empty string, one with NULL (no value), another with value + {:ok, _cfv1} = + CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member_with_value.id, + custom_field_id: field.id, + value: %{"_union_type" => "string", "_union_value" => "Zebra"} + }) + |> Ash.create() + + {:ok, _cfv2} = + CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member_with_empty.id, + custom_field_id: field.id, + value: %{"_union_type" => "string", "_union_value" => ""} + }) + |> Ash.create() + + # member_with_null has no custom field value (NULL) + + {:ok, _cfv3} = + CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member_with_another_value.id, + custom_field_id: field.id, + value: %{"_union_type" => "string", "_union_value" => "Apple"} + }) + |> Ash.create() + + conn = conn_with_oidc_user(conn) + {:ok, view, _html} = live(conn, "/members?query=&sort_field=custom_field_#{field.id}&sort_order=asc") + + html = render(view) + + # Find positions of member first names in the HTML to verify sort order + apple_pos = :binary.match(html, member_with_another_value.first_name) + zebra_pos = :binary.match(html, member_with_value.first_name) + empty_pos = :binary.match(html, member_with_empty.first_name) + null_pos = :binary.match(html, member_with_null.first_name) + + assert apple_pos != :nomatch, "AnotherValue (Apple) should be in HTML" + assert zebra_pos != :nomatch, "WithValue (Zebra) should be in HTML" + assert empty_pos != :nomatch, "WithEmpty should be in HTML" + assert null_pos != :nomatch, "WithNull should be in HTML" + + {apple_idx, _} = apple_pos + {zebra_idx, _} = zebra_pos + {empty_idx, _} = empty_pos + {null_idx, _} = null_pos + + # In ASC order: Apple should come before Zebra + assert apple_idx < zebra_idx, "Apple should come before Zebra in ASC order" + + # NULL and empty should come after all values + assert apple_idx < empty_idx, "Apple should come before empty value" + assert apple_idx < null_idx, "Apple should come before NULL value" + assert zebra_idx < empty_idx, "Zebra should come before empty value" + assert zebra_idx < null_idx, "Zebra should come before NULL value" + end + + test "NULL values and empty strings are always sorted last (DESC)", %{conn: conn} do + # Create additional members with NULL and empty string values + {:ok, member_with_value} = + Member + |> Ash.Changeset.for_create(:create_member, %{ + first_name: "WithValue", + last_name: "Test", + email: "withvalue@example.com" + }) + |> Ash.create() + + {:ok, member_with_empty} = + Member + |> Ash.Changeset.for_create(:create_member, %{ + first_name: "WithEmpty", + last_name: "Test", + email: "withempty@example.com" + }) + |> Ash.create() + + {:ok, member_with_null} = + Member + |> Ash.Changeset.for_create(:create_member, %{ + first_name: "WithNull", + last_name: "Test", + email: "withnull@example.com" + }) + |> Ash.create() + + {:ok, member_with_another_value} = + Member + |> Ash.Changeset.for_create(:create_member, %{ + first_name: "AnotherValue", + last_name: "Test", + email: "another@example.com" + }) + |> Ash.create() + + # Create custom field + {:ok, field} = + CustomField + |> Ash.Changeset.for_create(:create, %{ + name: "test_field", + value_type: :string, + show_in_overview: true + }) + |> Ash.create() + + # Create values: one with actual value, one with empty string, one with NULL (no value), another with value + {:ok, _cfv1} = + CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member_with_value.id, + custom_field_id: field.id, + value: %{"_union_type" => "string", "_union_value" => "Apple"} + }) + |> Ash.create() + + {:ok, _cfv2} = + CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member_with_empty.id, + custom_field_id: field.id, + value: %{"_union_type" => "string", "_union_value" => ""} + }) + |> Ash.create() + + # member_with_null has no custom field value (NULL) + + {:ok, _cfv3} = + CustomFieldValue + |> Ash.Changeset.for_create(:create, %{ + member_id: member_with_another_value.id, + custom_field_id: field.id, + value: %{"_union_type" => "string", "_union_value" => "Zebra"} + }) + |> Ash.create() + + conn = conn_with_oidc_user(conn) + {:ok, view, _html} = live(conn, "/members?query=&sort_field=custom_field_#{field.id}&sort_order=desc") + + html = render(view) + + # Find positions of member first names in the HTML to verify sort order + apple_pos = :binary.match(html, member_with_value.first_name) + zebra_pos = :binary.match(html, member_with_another_value.first_name) + empty_pos = :binary.match(html, member_with_empty.first_name) + null_pos = :binary.match(html, member_with_null.first_name) + + assert apple_pos != :nomatch, "WithValue (Apple) should be in HTML" + assert zebra_pos != :nomatch, "AnotherValue (Zebra) should be in HTML" + assert empty_pos != :nomatch, "WithEmpty should be in HTML" + assert null_pos != :nomatch, "WithNull should be in HTML" + + {apple_idx, _} = apple_pos + {zebra_idx, _} = zebra_pos + {empty_idx, _} = empty_pos + {null_idx, _} = null_pos + + # In DESC order: Zebra should come before Apple + assert zebra_idx < apple_idx, "Zebra should come before Apple in DESC order" + + # NULL and empty should come after all values + assert zebra_idx < empty_idx, "Zebra should come before empty value" + assert zebra_idx < null_idx, "Zebra should come before NULL value" + assert apple_idx < empty_idx, "Apple should come before empty value" + assert apple_idx < null_idx, "Apple should come before NULL value" + end +end From 11179e51f0d54242f6d4a2e6b555477d082f30cd Mon Sep 17 00:00:00 2001 From: carla Date: Wed, 26 Nov 2025 18:15:14 +0100 Subject: [PATCH 22/26] chore: show in overview attribute to custom field --- lib/membership/custom_field.ex | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/lib/membership/custom_field.ex b/lib/membership/custom_field.ex index e1cf397..5b7514c 100644 --- a/lib/membership/custom_field.ex +++ b/lib/membership/custom_field.ex @@ -14,6 +14,7 @@ defmodule Mv.Membership.CustomField do - `description` - Optional human-readable description - `immutable` - If true, custom field values cannot be changed after creation - `required` - If true, all members must have this custom field (future feature) + - `show_in_overview` - If true, this custom field will be displayed in the member overview table and can be sorted ## Supported Value Types - `:string` - Text data (max 10,000 characters) @@ -59,10 +60,10 @@ defmodule Mv.Membership.CustomField do actions do defaults [:read, :update] - default_accept [:name, :value_type, :description, :immutable, :required] + default_accept [:name, :value_type, :description, :immutable, :required, :show_in_overview] create :create do - accept [:name, :value_type, :description, :immutable, :required] + accept [:name, :value_type, :description, :immutable, :required, :show_in_overview] change Mv.Membership.CustomField.Changes.GenerateSlug validate string_length(:slug, min: 1) end @@ -119,6 +120,12 @@ defmodule Mv.Membership.CustomField do attribute :required, :boolean, default: false, allow_nil?: false + + attribute :show_in_overview, :boolean, + default: true, + allow_nil?: false, + public?: true, + description: "If true, this custom field will be displayed in the member overview table" end relationships do From 100ed96493ddfd669cfc0aad2c22e6d940c0653e Mon Sep 17 00:00:00 2001 From: carla Date: Wed, 26 Nov 2025 18:18:27 +0100 Subject: [PATCH 23/26] feat: adds dynamic cols to table core component --- lib/mv_web/components/core_components.ex | 33 ++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/lib/mv_web/components/core_components.ex b/lib/mv_web/components/core_components.ex index 656d3c0..24e5ffe 100644 --- a/lib/mv_web/components/core_components.ex +++ b/lib/mv_web/components/core_components.ex @@ -318,6 +318,13 @@ defmodule MvWeb.CoreComponents do default: &Function.identity/1, doc: "the function for mapping each row before calling the :col and :action slots" + attr :dynamic_cols, :list, + default: [], + doc: "list of dynamic column definitions with :custom_field and :render functions" + + attr :sort_field, :any, default: nil, doc: "current sort field" + attr :sort_order, :atom, default: nil, doc: "current sort order" + slot :col, required: true do attr :label, :string end @@ -335,6 +342,16 @@ defmodule MvWeb.CoreComponents do {col[:label]} + + <.live_component + module={MvWeb.Components.SortHeaderComponent} + id={:"sort_custom_field_#{dyn_col[:custom_field].id}"} + field={"custom_field_#{dyn_col[:custom_field].id}"} + label={dyn_col[:custom_field].name} + sort_field={@sort_field} + sort_order={@sort_order} + /> + {gettext("Actions")} @@ -349,6 +366,22 @@ defmodule MvWeb.CoreComponents do > {render_slot(col, @row_item.(row))} + + {if dyn_col[:render] do + rendered = dyn_col[:render].(@row_item.(row)) + if rendered == "" do + "" + else + rendered + end + else + "" + end} +
<%= for action <- @action do %> From e7c4a4f62fc9667401719954b7d33c32400c78dc Mon Sep 17 00:00:00 2001 From: carla Date: Wed, 26 Nov 2025 18:18:58 +0100 Subject: [PATCH 24/26] feat: add dynamic cols to member overview and checkbox to form --- docs/feature-roadmap.md | 7 +- lib/mv_web/live/custom_field_live/form.ex | 2 + lib/mv_web/live/member_live/index.ex | 407 ++++++++++++++++-- lib/mv_web/live/member_live/index.html.heex | 4 +- .../live/member_live/index/formatter.ex | 78 ++++ 5 files changed, 460 insertions(+), 38 deletions(-) create mode 100644 lib/mv_web/live/member_live/index/formatter.ex diff --git a/docs/feature-roadmap.md b/docs/feature-roadmap.md index 9a6517d..2313fd7 100644 --- a/docs/feature-roadmap.md +++ b/docs/feature-roadmap.md @@ -94,15 +94,18 @@ - ✅ CustomFieldValue type management - ✅ Dynamic custom field value assignment to members - ✅ Union type storage (JSONB) +- ✅ Default field visibility configuration + +**Closed Issues:** +- [#194](https://git.local-it.org/local-it/mitgliederverwaltung/issues/194) - Custom Fields: Harden implementation (S) +- [#197](https://git.local-it.org/local-it/mitgliederverwaltung/issues/197) - Custom Fields: Add option to show custom fields in member overview (M) **Open Issues:** -- [#194](https://git.local-it.org/local-it/mitgliederverwaltung/issues/194) - Custom Fields: Harden implementation (S) [0/3 tasks] - [#157](https://git.local-it.org/local-it/mitgliederverwaltung/issues/157) - Concept how custom fields are handled (M, High priority) [0/4 tasks] - [#161](https://git.local-it.org/local-it/mitgliederverwaltung/issues/161) - Don't show birthday field for default configurations (S, Low priority) - [#153](https://git.local-it.org/local-it/mitgliederverwaltung/issues/153) - Sorting functionalities for custom fields (M, Low priority) **Missing Features:** -- ❌ Default field visibility configuration - ❌ Field groups/categories - ❌ Conditional fields (show field X if field Y = value) - ❌ Field validation rules (min/max, regex patterns) diff --git a/lib/mv_web/live/custom_field_live/form.ex b/lib/mv_web/live/custom_field_live/form.ex index ab8f104..99317a9 100644 --- a/lib/mv_web/live/custom_field_live/form.ex +++ b/lib/mv_web/live/custom_field_live/form.ex @@ -18,6 +18,7 @@ defmodule MvWeb.CustomFieldLive.Form do - description - Human-readable explanation - immutable - If true, values cannot be changed after creation (default: false) - required - If true, all members must have this custom field (default: false) + - show_in_overview - If true, this custom field will be displayed in the member overview table (default: true) ## Value Type Selection - `:string` - Text data (unlimited length) @@ -60,6 +61,7 @@ defmodule MvWeb.CustomFieldLive.Form do <.input field={@form[:description]} type="text" label={gettext("Description")} /> <.input field={@form[:immutable]} type="checkbox" label={gettext("Immutable")} /> <.input field={@form[:required]} type="checkbox" label={gettext("Required")} /> + <.input field={@form[:show_in_overview]} type="checkbox" label={gettext("Show in overview")} /> <.button phx-disable-with={gettext("Saving...")} variant="primary"> {gettext("Save Custom field")} diff --git a/lib/mv_web/live/member_live/index.ex b/lib/mv_web/live/member_live/index.ex index c933133..4a134f2 100644 --- a/lib/mv_web/live/member_live/index.ex +++ b/lib/mv_web/live/member_live/index.ex @@ -26,6 +26,11 @@ defmodule MvWeb.MemberLive.Index do """ use MvWeb, :live_view + alias MvWeb.MemberLive.Index.Formatter + + # Prefix used in sort field names for custom fields (e.g., "custom_field_") + @custom_field_prefix "custom_field_" + @doc """ Initializes the LiveView state. @@ -34,6 +39,19 @@ defmodule MvWeb.MemberLive.Index do """ @impl true def mount(_params, _session, socket) do + # Load custom fields that should be shown in overview + require Ash.Query + import Ash.Expr + + # Note: Using Ash.read! (bang version) - errors will be handled by Phoenix LiveView + # and result in a 500 error page. This is appropriate for LiveViews where errors + # should be visible to the user rather than silently failing. + custom_fields_visible = + Mv.Membership.CustomField + |> Ash.Query.filter(expr(show_in_overview == true)) + |> Ash.Query.sort(name: :asc) + |> Ash.read!() + socket = socket |> assign(:page_title, gettext("Members")) @@ -41,6 +59,7 @@ defmodule MvWeb.MemberLive.Index do |> assign_new(:sort_field, fn -> :first_name end) |> assign_new(:sort_order, fn -> :asc end) |> assign(:selected_members, []) + |> assign(:custom_fields_visible, custom_fields_visible) # We call handle params to use the query from the URL {:ok, socket} @@ -60,6 +79,8 @@ defmodule MvWeb.MemberLive.Index do """ @impl true def handle_event("delete", %{"id" => id}, socket) do + # Note: Using bang versions (!) - errors will be handled by Phoenix LiveView + # This ensures users see error messages if deletion fails (e.g., permission denied) member = Ash.get!(Mv.Membership.Member, id) Ash.destroy!(member) @@ -108,7 +129,14 @@ defmodule MvWeb.MemberLive.Index do """ @impl true def handle_info({:sort, field_str}, socket) do - field = String.to_existing_atom(field_str) + # Handle both atom and string field names (for custom fields) + field = + try do + String.to_existing_atom(field_str) + rescue + ArgumentError -> field_str + end + {new_field, new_order} = determine_new_sort(field, socket) socket @@ -158,10 +186,37 @@ defmodule MvWeb.MemberLive.Index do |> maybe_update_search(params) |> maybe_update_sort(params) |> load_members(params["query"]) + |> prepare_dynamic_cols() {:noreply, socket} end + # Prepares dynamic column definitions for custom fields that should be shown in the overview. + # + # Creates a list of column definitions, each containing: + # - `:custom_field` - The CustomField resource + # - `:render` - A function that formats the custom field value for a given member + # + # Returns the socket with `:dynamic_cols` assigned. + defp prepare_dynamic_cols(socket) do + dynamic_cols = + Enum.map(socket.assigns.custom_fields_visible, fn custom_field -> + %{ + custom_field: custom_field, + render: fn member -> + case get_custom_field_value(member, custom_field) do + nil -> "" + cfv -> + formatted = Formatter.format_custom_field_value(cfv.value, custom_field) + if formatted == "", do: "", else: formatted + end + end + } + end) + + assign(socket, :dynamic_cols, dynamic_cols) + end + # ------------------------------------------------------------- # FUNCTIONS # ------------------------------------------------------------- @@ -177,8 +232,8 @@ defmodule MvWeb.MemberLive.Index do # Updates both the active and old SortHeader components defp update_sort_components(socket, old_field, new_field, new_order) do - active_id = :"sort_#{new_field}" - old_id = :"sort_#{old_field}" + active_id = to_sort_id(new_field) + old_id = to_sort_id(old_field) # Update the new SortHeader send_update(MvWeb.Components.SortHeaderComponent, @@ -197,11 +252,32 @@ defmodule MvWeb.MemberLive.Index do socket end + # Converts a field (atom or string) to a sort component ID atom + # Handles both existing atoms and strings that need to be converted + defp to_sort_id(field) when is_binary(field) do + try do + String.to_existing_atom("sort_#{field}") + rescue + ArgumentError -> :"sort_#{field}" + end + end + + defp to_sort_id(field) when is_atom(field) do + :"sort_#{field}" + end + # Builds sort URL and pushes navigation patch defp push_sort_url(socket, field, order) do + field_str = + if is_atom(field) do + Atom.to_string(field) + else + field + end + query_params = %{ "query" => socket.assigns.query, - "sort_field" => Atom.to_string(field), + "sort_field" => field_str, "sort_order" => Atom.to_string(order) } @@ -214,7 +290,25 @@ defmodule MvWeb.MemberLive.Index do )} end - # Load members eg based on a query for sorting + # Loads members from the database with custom field values and applies search/sort filters. + # + # Process: + # 1. Builds base query with selected fields + # 2. Loads custom field values for visible custom fields + # 3. Applies search filter if provided + # 4. Applies sorting (database-level for regular fields, in-memory for custom fields) + # 5. Filters custom field values to only visible ones (reduces memory usage) + # + # Performance Considerations: + # - In-memory sorting: Custom field sorting is done in memory after loading. + # This is suitable for small to medium datasets (<1000 members). + # For larger datasets, consider implementing database-level sorting or pagination. + # - Memory filtering: Custom field values are filtered after loading to reduce + # memory usage, but all members are still loaded into memory. + # - No pagination: All matching members are loaded at once. For large result sets, + # consider implementing pagination (see Issue #165). + # + # Returns the socket with `:members` assigned. defp load_members(socket, search_query) do query = Mv.Membership.Member @@ -232,16 +326,61 @@ defmodule MvWeb.MemberLive.Index do :join_date ]) + # Load custom field values for visible custom fields + custom_field_ids = Enum.map(socket.assigns.custom_fields_visible, & &1.id) + query = load_custom_field_values(query, custom_field_ids) + # Apply the search filter first query = apply_search_filter(query, search_query) # Apply sorting based on current socket state - query = maybe_sort(query, socket.assigns.sort_field, socket.assigns.sort_order) + # For custom fields, we sort after loading + {query, sort_after_load} = maybe_sort(query, socket.assigns.sort_field, socket.assigns.sort_order, socket.assigns.custom_fields_visible) + # Note: Using Ash.read! - errors will be handled by Phoenix LiveView + # This is appropriate for data loading in LiveViews members = Ash.read!(query) + + # Filter custom field values to only visible ones (reduces memory usage) + # Performance: This iterates through all members and their custom_field_values. + # For large datasets (>1000 members), this could be optimized by filtering + # at the database level, but requires more complex Ash queries. + custom_field_ids = MapSet.new(Enum.map(socket.assigns.custom_fields_visible, & &1.id)) + members = Enum.map(members, fn member -> + # Only filter if custom_field_values is loaded (is a list, not Ash.NotLoaded) + if is_list(member.custom_field_values) do + filtered_values = Enum.filter(member.custom_field_values, fn cfv -> + cfv.custom_field_id in custom_field_ids + end) + %{member | custom_field_values: filtered_values} + else + member + end + end) + + # Sort in memory if needed (for custom fields) + members = if sort_after_load do + sort_members_in_memory(members, socket.assigns.sort_field, socket.assigns.sort_order, socket.assigns.custom_fields_visible) + else + members + end + assign(socket, :members, members) end + # Load custom field values for the given custom field IDs + defp load_custom_field_values(query, []) do + query + end + + defp load_custom_field_values(query, custom_field_ids) when length(custom_field_ids) > 0 do + # Load all custom field values with their custom_field relationship + # Note: We filter to visible custom fields after loading to reduce memory usage + # Ash loads relationships efficiently with JOINs, but we only keep visible ones + query + |> Ash.Query.load(custom_field_values: [custom_field: [:id, :name, :value_type]]) + end + # ------------------------------------------------------------- # Helper Functions # ------------------------------------------------------------- @@ -264,15 +403,24 @@ defmodule MvWeb.MemberLive.Index do defp toggle_order(nil), do: :asc # Function to sort the column if needed - defp maybe_sort(query, nil, _), do: query + # Returns {query, sort_after_load} where sort_after_load is true if we need to sort in memory + defp maybe_sort(query, nil, _, _), do: {query, false} - defp maybe_sort(query, field, :asc) when not is_nil(field), - do: Ash.Query.sort(query, [{field, :asc}]) + defp maybe_sort(query, field, order, _custom_fields) when not is_nil(field) do + if custom_field_sort?(field) do + # Custom fields need to be sorted in memory after loading + {query, true} + else + # Only sort by atom fields (regular member fields) in database + if is_atom(field) do + {Ash.Query.sort(query, [{field, order}]), false} + else + {query, false} + end + end + end - defp maybe_sort(query, field, :desc) when not is_nil(field), - do: Ash.Query.sort(query, [{field, :desc}]) - - defp maybe_sort(query, _, _), do: query + defp maybe_sort(query, _, _, _), do: {query, false} # Validate that a field is sortable defp valid_sort_field?(field) when is_atom(field) do @@ -288,12 +436,156 @@ defmodule MvWeb.MemberLive.Index do :join_date ] - field in valid_fields + field in valid_fields or custom_field_sort?(field) + end + + defp valid_sort_field?(field) when is_binary(field) do + custom_field_sort?(field) end defp valid_sort_field?(_), do: false - # Function to maybe update the sort + # Check if field is a custom field sort field (format: custom_field_) + defp custom_field_sort?(field) when is_atom(field) do + field_str = Atom.to_string(field) + String.starts_with?(field_str, @custom_field_prefix) + end + + defp custom_field_sort?(field) when is_binary(field) do + String.starts_with?(field, @custom_field_prefix) + end + + defp custom_field_sort?(_), do: false + + # Extracts the custom field ID from a sort field name. + # + # Sort fields for custom fields use the format: "custom_field_" + # This function extracts the ID part. + # + # Examples: + # extract_custom_field_id("custom_field_123") -> "123" + # extract_custom_field_id(:custom_field_123) -> "123" + # extract_custom_field_id("first_name") -> nil + defp extract_custom_field_id(field) when is_atom(field) do + field_str = Atom.to_string(field) + extract_custom_field_id(field_str) + end + + defp extract_custom_field_id(field) when is_binary(field) do + case String.split(field, @custom_field_prefix) do + ["", id_str] -> id_str + _ -> nil + end + end + + defp extract_custom_field_id(_), do: nil + + # Sorts members in memory by a custom field value. + # + # Process: + # 1. Extracts custom field ID from sort field name + # 2. Finds the corresponding CustomField resource + # 3. Splits members into those with values and those without + # 4. Sorts members with values by the extracted value + # 5. Combines: sorted values first, then NULL/empty values at the end + # + # Performance Note: + # This function sorts in memory, which is suitable for small to medium datasets (<1000 members). + # For larger datasets, consider implementing database-level sorting or pagination. + # + # Parameters: + # - `members` - List of Member resources to sort + # - `field` - Sort field name (format: "custom_field_" or atom) + # - `order` - Sort order (`:asc` or `:desc`) + # - `custom_fields` - List of visible CustomField resources + # + # Returns the sorted list of members. + defp sort_members_in_memory(members, field, order, custom_fields) do + custom_field_id_str = extract_custom_field_id(field) + + case custom_field_id_str do + nil -> + members + + id_str -> + # Find the custom field by matching the ID string + custom_field = + Enum.find(custom_fields, fn cf -> + to_string(cf.id) == id_str + end) + + case custom_field do + nil -> + members + + cf -> + # Split members into those with values and those without (NULL/empty) + {members_with_values, members_without_values} = + Enum.split_with(members, fn member -> + case get_custom_field_value(member, cf) do + nil -> false + cfv -> + extracted = extract_sort_value(cfv.value, cf.value_type) + not is_empty_value(extracted, cf.value_type) + end + end) + + # Sort members with values + sorted_with_values = Enum.sort_by(members_with_values, fn member -> + cfv = get_custom_field_value(member, cf) + extracted = extract_sort_value(cfv.value, cf.value_type) + normalize_sort_value(extracted, order) + end) + + # For DESC, reverse only the members with values + sorted_with_values = if order == :desc do + Enum.reverse(sorted_with_values) + else + sorted_with_values + end + + # Combine: sorted values first, then NULL/empty values at the end + sorted_with_values ++ members_without_values + end + end + end + + # Extracts a sortable value from a custom field value based on its type. + # + # Handles different value formats: + # - `%Ash.Union{}` - Extracts value and type from union + # - Direct values - Returns as-is for primitive types + # + # Returns the extracted value suitable for sorting. + defp extract_sort_value(%Ash.Union{value: value, type: type}, _expected_type) do + extract_sort_value(value, type) + end + + defp extract_sort_value(value, :string) when is_binary(value), do: value + defp extract_sort_value(value, :integer) when is_integer(value), do: value + defp extract_sort_value(value, :boolean) when is_boolean(value), do: value + defp extract_sort_value(%Date{} = date, :date), do: date + defp extract_sort_value(value, :email) when is_binary(value), do: value + defp extract_sort_value(value, _type), do: to_string(value) + + # Check if a value is considered empty (NULL or empty string) + defp is_empty_value(value, :string) when is_binary(value) do + String.trim(value) == "" + end + defp is_empty_value(value, :email) when is_binary(value) do + String.trim(value) == "" + end + defp is_empty_value(_value, _type), do: false + + # Normalize sort value for DESC order + # For DESC, we sort ascending first, then reverse the list + # This function is kept for consistency but doesn't need to invert values + defp normalize_sort_value(value, _order), do: value + + + # Updates sort field and order from URL parameters if present. + # + # Validates the sort field and order, falling back to defaults if invalid. defp maybe_update_sort(socket, %{"sort_field" => sf, "sort_order" => so}) do field = determine_field(socket.assigns.sort_field, sf) order = determine_order(socket.assigns.sort_order, so) @@ -305,33 +597,50 @@ defmodule MvWeb.MemberLive.Index do defp maybe_update_sort(socket, _), do: socket - defp determine_field(default, sf) do - case sf do - "" -> - default + # Determine sort field from URL parameter, validating against allowed fields + defp determine_field(default, ""), do: default + defp determine_field(default, nil), do: default - nil -> - default - - sf when is_binary(sf) -> - sf - |> String.to_existing_atom() - |> handle_atom_conversion(default) - - sf when is_atom(sf) -> - handle_atom_conversion(sf, default) - - _ -> - default + # Determines the valid sort field from a URL parameter. + # + # Validates the field against allowed sort fields (regular member fields or custom fields). + # Falls back to default if the field is invalid. + # + # Parameters: + # - `default` - Default field to use if validation fails + # - `sf` - Sort field from URL (can be atom, string, nil, or empty string) + # + # Returns a valid sort field (atom or string for custom fields). + defp determine_field(default, sf) when is_binary(sf) do + # Check if it's a custom field sort (starts with "custom_field_") + if custom_field_sort?(sf) do + if valid_sort_field?(sf), do: sf, else: default + else + # Try to convert to atom for regular fields + try do + atom = String.to_existing_atom(sf) + if valid_sort_field?(atom), do: atom, else: default + rescue + ArgumentError -> default + end end end - defp handle_atom_conversion(val, default) when is_atom(val) do - if valid_sort_field?(val), do: val, else: default + defp determine_field(default, sf) when is_atom(sf) do + if valid_sort_field?(sf), do: sf, else: default end - defp handle_atom_conversion(_, default), do: default + defp determine_field(default, _), do: default + # Determines the valid sort order from a URL parameter. + # + # Validates that the order is either "asc" or "desc", falling back to default if invalid. + # + # Parameters: + # - `default` - Default order to use if validation fails + # - `so` - Sort order from URL (string, atom, nil, or empty string) + # + # Returns `:asc` or `:desc`. defp determine_order(default, so) do case so do "" -> default @@ -350,4 +659,32 @@ defmodule MvWeb.MemberLive.Index do # Keep the previous search query if no new one is provided socket end + + # ------------------------------------------------------------- + # Helper Functions for Custom Field Values + # ------------------------------------------------------------- + + # Retrieves the custom field value for a specific member and custom field. + # + # Searches through the member's `custom_field_values` relationship to find + # the value matching the given custom field. + # + # Returns: + # - `%CustomFieldValue{}` if found + # - `nil` if not found or if member has no custom field values + # + # Examples: + # get_custom_field_value(member, custom_field) -> %CustomFieldValue{...} + # get_custom_field_value(member, non_existent_field) -> nil + def get_custom_field_value(member, custom_field) do + case member.custom_field_values do + nil -> nil + values when is_list(values) -> + Enum.find(values, fn cfv -> + cfv.custom_field_id == custom_field.id or + (cfv.custom_field && cfv.custom_field.id == custom_field.id) + end) + _ -> nil + end + end end diff --git a/lib/mv_web/live/member_live/index.html.heex b/lib/mv_web/live/member_live/index.html.heex index cb2ccd8..67fa804 100644 --- a/lib/mv_web/live/member_live/index.html.heex +++ b/lib/mv_web/live/member_live/index.html.heex @@ -19,6 +19,9 @@ id="members" rows={@members} row_click={fn member -> JS.navigate(~p"/members/#{member}") end} + dynamic_cols={@dynamic_cols} + sort_field={@sort_field} + sort_order={@sort_order} > @@ -185,7 +188,6 @@ > {member.join_date} - <:action :let={member}>
<.link navigate={~p"/members/#{member}"}>{gettext("Show")} diff --git a/lib/mv_web/live/member_live/index/formatter.ex b/lib/mv_web/live/member_live/index/formatter.ex new file mode 100644 index 0000000..d97966c --- /dev/null +++ b/lib/mv_web/live/member_live/index/formatter.ex @@ -0,0 +1,78 @@ +defmodule MvWeb.MemberLive.Index.Formatter do + @moduledoc """ + Formats custom field values for display in the member overview table. + + Handles different value types (string, integer, boolean, date, email) and + formats them appropriately for display in the UI. + """ + use Gettext, backend: MvWeb.Gettext + + @doc """ + Formats a custom field value for display. + + Handles different input formats: + - `nil` - Returns empty string + - `%Ash.Union{}` - Extracts value and type from union type + - Map (JSONB format) - Extracts type and value from map keys + - Direct value - Uses custom_field.value_type to determine format + + ## Examples + + iex> format_custom_field_value(nil, %CustomField{value_type: :string}) + "" + + iex> format_custom_field_value("test", %CustomField{value_type: :string}) + "test" + + iex> format_custom_field_value(true, %CustomField{value_type: :boolean}) + "Yes" + """ + def format_custom_field_value(nil, _custom_field), do: "" + + def format_custom_field_value(%Ash.Union{value: value, type: type}, custom_field) do + format_value_by_type(value, type, custom_field) + end + + def format_custom_field_value(value, custom_field) when is_map(value) do + # Handle map format from JSONB + type = Map.get(value, "type") || Map.get(value, "_union_type") + val = Map.get(value, "value") || Map.get(value, "_union_value") + format_value_by_type(val, type, custom_field) + end + + def format_custom_field_value(value, custom_field) do + format_value_by_type(value, custom_field.value_type, custom_field) + end + + # Format value based on type + defp format_value_by_type(value, :string, _) when is_binary(value) do + # Return empty string if value is empty, otherwise return the value + if String.trim(value) == "", do: "", else: value + end + + defp format_value_by_type(value, :string, _), do: to_string(value) + + defp format_value_by_type(value, :integer, _), do: to_string(value) + + defp format_value_by_type(value, :email, _) when is_binary(value) do + # Return empty string if value is empty + if String.trim(value) == "", do: "", else: value + end + + defp format_value_by_type(value, :email, _), do: to_string(value) + + defp format_value_by_type(value, :boolean, _) when value == true, do: gettext("Yes") + defp format_value_by_type(value, :boolean, _) when value == false, do: gettext("No") + defp format_value_by_type(value, :boolean, _), do: to_string(value) + + defp format_value_by_type(%Date{} = date, :date, _), do: Date.to_string(date) + + defp format_value_by_type(value, :date, _) when is_binary(value) do + case Date.from_iso8601(value) do + {:ok, date} -> Date.to_string(date) + _ -> value + end + end + + defp format_value_by_type(value, _type, _), do: to_string(value) +end From 82bd5732768b92ab875ccf868a91ed3a388b967d Mon Sep 17 00:00:00 2001 From: carla Date: Thu, 27 Nov 2025 14:10:27 +0100 Subject: [PATCH 25/26] formatting --- lib/mv_web/components/core_components.ex | 1 + lib/mv_web/live/member_live/index.ex | 179 ++++++++++++------ ...index_custom_fields_accessibility_test.exs | 8 +- .../index_custom_fields_display_test.exs | 2 +- .../index_custom_fields_edge_cases_test.exs | 1 - .../index_custom_fields_sorting_test.exs | 25 ++- 6 files changed, 148 insertions(+), 68 deletions(-) diff --git a/lib/mv_web/components/core_components.ex b/lib/mv_web/components/core_components.ex index 24e5ffe..b8fe0fc 100644 --- a/lib/mv_web/components/core_components.ex +++ b/lib/mv_web/components/core_components.ex @@ -373,6 +373,7 @@ defmodule MvWeb.CoreComponents do > {if dyn_col[:render] do rendered = dyn_col[:render].(@row_item.(row)) + if rendered == "" do "" else diff --git a/lib/mv_web/live/member_live/index.ex b/lib/mv_web/live/member_live/index.ex index 4a134f2..419df17 100644 --- a/lib/mv_web/live/member_live/index.ex +++ b/lib/mv_web/live/member_live/index.ex @@ -205,7 +205,9 @@ defmodule MvWeb.MemberLive.Index do custom_field: custom_field, render: fn member -> case get_custom_field_value(member, custom_field) do - nil -> "" + nil -> + "" + cfv -> formatted = Formatter.format_custom_field_value(cfv.value, custom_field) if formatted == "", do: "", else: formatted @@ -335,7 +337,13 @@ defmodule MvWeb.MemberLive.Index do # Apply sorting based on current socket state # For custom fields, we sort after loading - {query, sort_after_load} = maybe_sort(query, socket.assigns.sort_field, socket.assigns.sort_order, socket.assigns.custom_fields_visible) + {query, sort_after_load} = + maybe_sort( + query, + socket.assigns.sort_field, + socket.assigns.sort_order, + socket.assigns.custom_fields_visible + ) # Note: Using Ash.read! - errors will be handled by Phoenix LiveView # This is appropriate for data loading in LiveViews @@ -346,24 +354,21 @@ defmodule MvWeb.MemberLive.Index do # For large datasets (>1000 members), this could be optimized by filtering # at the database level, but requires more complex Ash queries. custom_field_ids = MapSet.new(Enum.map(socket.assigns.custom_fields_visible, & &1.id)) - members = Enum.map(members, fn member -> - # Only filter if custom_field_values is loaded (is a list, not Ash.NotLoaded) - if is_list(member.custom_field_values) do - filtered_values = Enum.filter(member.custom_field_values, fn cfv -> - cfv.custom_field_id in custom_field_ids - end) - %{member | custom_field_values: filtered_values} - else - member - end - end) + + members = filter_member_custom_field_values(members, custom_field_ids) # Sort in memory if needed (for custom fields) - members = if sort_after_load do - sort_members_in_memory(members, socket.assigns.sort_field, socket.assigns.sort_order, socket.assigns.custom_fields_visible) - else - members - end + members = + if sort_after_load do + sort_members_in_memory( + members, + socket.assigns.sort_field, + socket.assigns.sort_order, + socket.assigns.custom_fields_visible + ) + else + members + end assign(socket, :members, members) end @@ -381,6 +386,28 @@ defmodule MvWeb.MemberLive.Index do |> Ash.Query.load(custom_field_values: [custom_field: [:id, :name, :value_type]]) end + # Filters custom field values to only visible ones for all members + defp filter_member_custom_field_values(members, custom_field_ids) do + Enum.map(members, fn member -> + filter_single_member_custom_field_values(member, custom_field_ids) + end) + end + + # Filters custom field values for a single member + defp filter_single_member_custom_field_values(member, _custom_field_ids) + when not is_list(member.custom_field_values) do + member + end + + defp filter_single_member_custom_field_values(member, custom_field_ids) do + filtered_values = + Enum.filter(member.custom_field_values, fn cfv -> + cfv.custom_field_id in custom_field_ids + end) + + %{member | custom_field_values: filtered_values} + end + # ------------------------------------------------------------- # Helper Functions # ------------------------------------------------------------- @@ -508,45 +535,76 @@ defmodule MvWeb.MemberLive.Index do members id_str -> - # Find the custom field by matching the ID string - custom_field = - Enum.find(custom_fields, fn cf -> - to_string(cf.id) == id_str - end) + sort_members_by_custom_field(members, id_str, order, custom_fields) + end + end - case custom_field do - nil -> - members + # Sorts members by a specific custom field ID + defp sort_members_by_custom_field(members, id_str, order, custom_fields) do + custom_field = find_custom_field_by_id(custom_fields, id_str) - cf -> - # Split members into those with values and those without (NULL/empty) - {members_with_values, members_without_values} = - Enum.split_with(members, fn member -> - case get_custom_field_value(member, cf) do - nil -> false - cfv -> - extracted = extract_sort_value(cfv.value, cf.value_type) - not is_empty_value(extracted, cf.value_type) - end - end) + case custom_field do + nil -> + members - # Sort members with values - sorted_with_values = Enum.sort_by(members_with_values, fn member -> - cfv = get_custom_field_value(member, cf) - extracted = extract_sort_value(cfv.value, cf.value_type) - normalize_sort_value(extracted, order) - end) + cf -> + sort_members_with_custom_field(members, cf, order) + end + end - # For DESC, reverse only the members with values - sorted_with_values = if order == :desc do - Enum.reverse(sorted_with_values) - else - sorted_with_values - end + # Finds a custom field by matching its ID string + defp find_custom_field_by_id(custom_fields, id_str) do + Enum.find(custom_fields, fn cf -> + to_string(cf.id) == id_str + end) + end - # Combine: sorted values first, then NULL/empty values at the end - sorted_with_values ++ members_without_values - end + # Sorts members that have a specific custom field + defp sort_members_with_custom_field(members, custom_field, order) do + # Split members into those with values and those without (NULL/empty) + {members_with_values, members_without_values} = + split_members_by_value_presence(members, custom_field) + + # Sort members with values + sorted_with_values = sort_members_with_values(members_with_values, custom_field, order) + + # Combine: sorted values first, then NULL/empty values at the end + sorted_with_values ++ members_without_values + end + + # Splits members into those with values and those without + defp split_members_by_value_presence(members, custom_field) do + Enum.split_with(members, fn member -> + has_non_empty_value?(member, custom_field) + end) + end + + # Checks if a member has a non-empty value for the custom field + defp has_non_empty_value?(member, custom_field) do + case get_custom_field_value(member, custom_field) do + nil -> + false + + cfv -> + extracted = extract_sort_value(cfv.value, custom_field.value_type) + not empty_value?(extracted, custom_field.value_type) + end + end + + # Sorts members that have values for the custom field + defp sort_members_with_values(members_with_values, custom_field, order) do + sorted = + Enum.sort_by(members_with_values, fn member -> + cfv = get_custom_field_value(member, custom_field) + extracted = extract_sort_value(cfv.value, custom_field.value_type) + normalize_sort_value(extracted, order) + end) + + # For DESC, reverse only the members with values + if order == :desc do + Enum.reverse(sorted) + else + sorted end end @@ -569,20 +627,21 @@ defmodule MvWeb.MemberLive.Index do defp extract_sort_value(value, _type), do: to_string(value) # Check if a value is considered empty (NULL or empty string) - defp is_empty_value(value, :string) when is_binary(value) do + defp empty_value?(value, :string) when is_binary(value) do String.trim(value) == "" end - defp is_empty_value(value, :email) when is_binary(value) do + + defp empty_value?(value, :email) when is_binary(value) do String.trim(value) == "" end - defp is_empty_value(_value, _type), do: false + + defp empty_value?(_value, _type), do: false # Normalize sort value for DESC order # For DESC, we sort ascending first, then reverse the list # This function is kept for consistency but doesn't need to invert values defp normalize_sort_value(value, _order), do: value - # Updates sort field and order from URL parameters if present. # # Validates the sort field and order, falling back to defaults if invalid. @@ -678,13 +737,17 @@ defmodule MvWeb.MemberLive.Index do # get_custom_field_value(member, non_existent_field) -> nil def get_custom_field_value(member, custom_field) do case member.custom_field_values do - nil -> nil + nil -> + nil + values when is_list(values) -> Enum.find(values, fn cfv -> cfv.custom_field_id == custom_field.id or (cfv.custom_field && cfv.custom_field.id == custom_field.id) end) - _ -> nil + + _ -> + nil end end end diff --git a/test/mv_web/member_live/index_custom_fields_accessibility_test.exs b/test/mv_web/member_live/index_custom_fields_accessibility_test.exs index e4d174f..cfe3145 100644 --- a/test/mv_web/member_live/index_custom_fields_accessibility_test.exs +++ b/test/mv_web/member_live/index_custom_fields_accessibility_test.exs @@ -67,7 +67,9 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsAccessibilityTest do field: field } do conn = conn_with_oidc_user(conn) - {:ok, view, _html} = live(conn, "/members?query=&sort_field=custom_field_#{field.id}&sort_order=asc") + + {:ok, view, _html} = + live(conn, "/members?query=&sort_field=custom_field_#{field.id}&sort_order=asc") html = render(view) @@ -80,7 +82,9 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsAccessibilityTest do field: field } do conn = conn_with_oidc_user(conn) - {:ok, view, _html} = live(conn, "/members?query=&sort_field=custom_field_#{field.id}&sort_order=desc") + + {:ok, view, _html} = + live(conn, "/members?query=&sort_field=custom_field_#{field.id}&sort_order=desc") html = render(view) diff --git a/test/mv_web/member_live/index_custom_fields_display_test.exs b/test/mv_web/member_live/index_custom_fields_display_test.exs index 7788c60..25aefe5 100644 --- a/test/mv_web/member_live/index_custom_fields_display_test.exs +++ b/test/mv_web/member_live/index_custom_fields_display_test.exs @@ -105,7 +105,7 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsDisplayTest do |> Ash.Changeset.for_create(:create, %{ member_id: member1.id, custom_field_id: field_show_integer.id, - value: %{"_union_type" => "integer", "_union_value" => 12345} + value: %{"_union_type" => "integer", "_union_value" => 12_345} }) |> Ash.create() diff --git a/test/mv_web/member_live/index_custom_fields_edge_cases_test.exs b/test/mv_web/member_live/index_custom_fields_edge_cases_test.exs index 9d44c40..d526556 100644 --- a/test/mv_web/member_live/index_custom_fields_edge_cases_test.exs +++ b/test/mv_web/member_live/index_custom_fields_edge_cases_test.exs @@ -171,4 +171,3 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsEdgeCasesTest do assert html =~ "Value3" end end - diff --git a/test/mv_web/member_live/index_custom_fields_sorting_test.exs b/test/mv_web/member_live/index_custom_fields_sorting_test.exs index e1c99b2..21b0c9f 100644 --- a/test/mv_web/member_live/index_custom_fields_sorting_test.exs +++ b/test/mv_web/member_live/index_custom_fields_sorting_test.exs @@ -174,7 +174,9 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsSortingTest do test "sorting by custom field works with URL parameters", %{conn: conn, field_string: field} do conn = conn_with_oidc_user(conn) - {:ok, view, _html} = live(conn, "/members?query=&sort_field=custom_field_#{field.id}&sort_order=desc") + + {:ok, view, _html} = + live(conn, "/members?query=&sort_field=custom_field_#{field.id}&sort_order=desc") # Check that the sort state is correctly applied assert has_element?(view, "[data-testid='custom_field_#{field.id}'][aria-label='descending']") @@ -186,14 +188,19 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsSortingTest do field_integer: field_integer } do conn = conn_with_oidc_user(conn) - {:ok, view, _html} = live(conn, "/members?query=&sort_field=custom_field_#{field_string.id}&sort_order=desc") + + {:ok, view, _html} = + live(conn, "/members?query=&sort_field=custom_field_#{field_string.id}&sort_order=desc") # Click on a different custom field column view |> element("[data-testid='custom_field_#{field_integer.id}']") |> render_click() - assert_patch(view, "/members?query=&sort_field=custom_field_#{field_integer.id}&sort_order=asc") + assert_patch( + view, + "/members?query=&sort_field=custom_field_#{field_integer.id}&sort_order=asc" + ) end test "clicking regular column after custom field column works", %{ @@ -201,7 +208,9 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsSortingTest do field_string: field } do conn = conn_with_oidc_user(conn) - {:ok, view, _html} = live(conn, "/members?query=&sort_field=custom_field_#{field.id}&sort_order=desc") + + {:ok, view, _html} = + live(conn, "/members?query=&sort_field=custom_field_#{field.id}&sort_order=desc") # Click on email column view @@ -305,7 +314,9 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsSortingTest do |> Ash.create() conn = conn_with_oidc_user(conn) - {:ok, view, _html} = live(conn, "/members?query=&sort_field=custom_field_#{field.id}&sort_order=asc") + + {:ok, view, _html} = + live(conn, "/members?query=&sort_field=custom_field_#{field.id}&sort_order=asc") html = render(view) @@ -414,7 +425,9 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsSortingTest do |> Ash.create() conn = conn_with_oidc_user(conn) - {:ok, view, _html} = live(conn, "/members?query=&sort_field=custom_field_#{field.id}&sort_order=desc") + + {:ok, view, _html} = + live(conn, "/members?query=&sort_field=custom_field_#{field.id}&sort_order=desc") html = render(view) From 2284cd93c472a50c70f2cbd57d000766bfd219b8 Mon Sep 17 00:00:00 2001 From: carla Date: Thu, 27 Nov 2025 14:14:53 +0100 Subject: [PATCH 26/26] translate: add translation --- priv/gettext/de/LC_MESSAGES/default.po | 77 +++++++++------- priv/gettext/default.pot | 117 ++++++++----------------- priv/gettext/en/LC_MESSAGES/default.po | 77 +++++++++------- 3 files changed, 125 insertions(+), 146 deletions(-) diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po index 7b8c86e..27acc80 100644 --- a/priv/gettext/de/LC_MESSAGES/default.po +++ b/priv/gettext/de/LC_MESSAGES/default.po @@ -10,13 +10,13 @@ msgid "" msgstr "" "Language: en\n" -#: lib/mv_web/components/core_components.ex:339 +#: lib/mv_web/components/core_components.ex:356 #, elixir-autogen, elixir-format msgid "Actions" msgstr "Aktionen" -#: lib/mv_web/live/member_live/index.html.heex:200 -#: lib/mv_web/live/user_live/index.html.heex:72 +#: lib/mv_web/live/member_live/index.html.heex:202 +#: lib/mv_web/live/user_live/index.html.heex:65 #, elixir-autogen, elixir-format msgid "Are you sure?" msgstr "Bist du sicher?" @@ -28,21 +28,21 @@ 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:145 +#: lib/mv_web/live/member_live/index.html.heex:148 #: 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:202 -#: lib/mv_web/live/user_live/index.html.heex:74 +#: lib/mv_web/live/member_live/index.html.heex:204 +#: lib/mv_web/live/user_live/index.html.heex:67 #, elixir-autogen, elixir-format msgid "Delete" msgstr "Löschen" -#: lib/mv_web/live/member_live/index.html.heex:194 -#: lib/mv_web/live/user_live/form.ex:251 -#: lib/mv_web/live/user_live/index.html.heex:66 +#: lib/mv_web/live/member_live/index.html.heex:196 +#: lib/mv_web/live/user_live/form.ex:141 +#: lib/mv_web/live/user_live/index.html.heex:59 #, elixir-autogen, elixir-format msgid "Edit" msgstr "Bearbeite" @@ -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:77 +#: lib/mv_web/live/member_live/index.html.heex:80 #: 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:179 +#: lib/mv_web/live/member_live/index.html.heex:182 #: lib/mv_web/live/member_live/show.ex:56 #, elixir-autogen, elixir-format msgid "Join Date" @@ -87,8 +87,8 @@ msgstr "Nachname" msgid "New Member" msgstr "Neues Mitglied" -#: lib/mv_web/live/member_live/index.html.heex:191 -#: lib/mv_web/live/user_live/index.html.heex:63 +#: lib/mv_web/live/member_live/index.html.heex:193 +#: lib/mv_web/live/user_live/index.html.heex:56 #, elixir-autogen, elixir-format msgid "Show" msgstr "Anzeigen" @@ -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:111 +#: lib/mv_web/live/member_live/index.html.heex:114 #: lib/mv_web/live/member_live/show.ex:61 #, elixir-autogen, elixir-format msgid "House Number" @@ -140,14 +140,14 @@ msgid "Paid" msgstr "Bezahlt" #: lib/mv_web/live/member_live/form.ex:50 -#: lib/mv_web/live/member_live/index.html.heex:162 +#: lib/mv_web/live/member_live/index.html.heex:165 #: 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:128 +#: lib/mv_web/live/member_live/index.html.heex:131 #: lib/mv_web/live/member_live/show.ex:62 #, elixir-autogen, elixir-format msgid "Postal Code" @@ -158,7 +158,7 @@ msgstr "Postleitzahl" msgid "Save Member" msgstr "Mitglied speichern" -#: lib/mv_web/live/custom_field_live/form.ex:64 +#: lib/mv_web/live/custom_field_live/form.ex:66 #: lib/mv_web/live/custom_field_value_live/form.ex:74 #: lib/mv_web/live/member_live/form.ex:79 #: lib/mv_web/live/user_live/form.ex:234 @@ -167,7 +167,7 @@ msgid "Saving..." msgstr "Speichern..." #: lib/mv_web/live/member_live/form.ex:55 -#: lib/mv_web/live/member_live/index.html.heex:94 +#: lib/mv_web/live/member_live/index.html.heex:97 #: lib/mv_web/live/member_live/show.ex:60 #, elixir-autogen, elixir-format msgid "Street" @@ -183,6 +183,7 @@ msgstr "Dieses Formular dient zur Verwaltung von Mitgliedern und deren Eigenscha msgid "Id" msgstr "ID" +#: lib/mv_web/live/member_live/index/formatter.ex:65 #: lib/mv_web/live/member_live/show.ex:53 #, elixir-autogen, elixir-format msgid "No" @@ -198,19 +199,20 @@ 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/formatter.ex:64 #: lib/mv_web/live/member_live/show.ex:53 #, elixir-autogen, elixir-format msgid "Yes" msgstr "Ja" -#: lib/mv_web/live/custom_field_live/form.ex:108 +#: lib/mv_web/live/custom_field_live/form.ex:110 #: lib/mv_web/live/custom_field_value_live/form.ex:233 #: lib/mv_web/live/member_live/form.ex:138 #, elixir-autogen, elixir-format msgid "create" msgstr "erstellt" -#: lib/mv_web/live/custom_field_live/form.ex:109 +#: lib/mv_web/live/custom_field_live/form.ex:111 #: lib/mv_web/live/custom_field_value_live/form.ex:234 #: lib/mv_web/live/member_live/form.ex:139 #, elixir-autogen, elixir-format @@ -252,7 +254,7 @@ msgstr "Ihre E-Mail-Adresse wurde bestätigt" msgid "Your password has successfully been reset" msgstr "Ihr Passwort wurde erfolgreich zurückgesetzt" -#: lib/mv_web/live/custom_field_live/form.ex:67 +#: lib/mv_web/live/custom_field_live/form.ex:69 #: lib/mv_web/live/custom_field_live/index.ex:120 #: lib/mv_web/live/custom_field_value_live/form.ex:77 #: lib/mv_web/live/member_live/form.ex:82 @@ -266,7 +268,7 @@ msgstr "Abbrechen" msgid "Choose a member" msgstr "Mitglied auswählen" -#: lib/mv_web/live/custom_field_live/form.ex:60 +#: lib/mv_web/live/custom_field_live/form.ex:61 #, elixir-autogen, elixir-format msgid "Description" msgstr "Beschreibung" @@ -286,7 +288,7 @@ msgstr "Aktiviert" msgid "ID" msgstr "ID" -#: lib/mv_web/live/custom_field_live/form.ex:61 +#: lib/mv_web/live/custom_field_live/form.ex:62 #, elixir-autogen, elixir-format msgid "Immutable" msgstr "Unveränderlich" @@ -308,13 +310,13 @@ msgid "Member" msgstr "Mitglied" #: lib/mv_web/components/layouts/navbar.ex:19 -#: lib/mv_web/live/member_live/index.ex:39 +#: lib/mv_web/live/member_live/index.ex:57 #: lib/mv_web/live/member_live/index.html.heex:3 #, elixir-autogen, elixir-format msgid "Members" msgstr "Mitglieder" -#: lib/mv_web/live/custom_field_live/form.ex:50 +#: lib/mv_web/live/custom_field_live/form.ex:51 #, elixir-autogen, elixir-format msgid "Name" msgstr "Name" @@ -357,17 +359,17 @@ msgstr "Passwort-Authentifizierung" msgid "Profil" msgstr "Profil" -#: lib/mv_web/live/custom_field_live/form.ex:62 +#: lib/mv_web/live/custom_field_live/form.ex:63 #, elixir-autogen, elixir-format msgid "Required" msgstr "Erforderlich" -#: lib/mv_web/live/member_live/index.html.heex:34 +#: lib/mv_web/live/member_live/index.html.heex:37 #, elixir-autogen, elixir-format msgid "Select all members" msgstr "Alle Mitglieder auswählen" -#: lib/mv_web/live/member_live/index.html.heex:48 +#: lib/mv_web/live/member_live/index.html.heex:51 #, elixir-autogen, elixir-format msgid "Select member" msgstr "Mitglied auswählen" @@ -413,7 +415,7 @@ msgstr "Benutzer*in" msgid "Value" msgstr "Wert" -#: lib/mv_web/live/custom_field_live/form.ex:55 +#: lib/mv_web/live/custom_field_live/form.ex:56 #, elixir-autogen, elixir-format msgid "Value type" msgstr "Wertetyp" @@ -569,7 +571,7 @@ msgstr "Benutzer*innen" msgid "Click to sort" msgstr "Klicke um zu sortieren" -#: lib/mv_web/live/member_live/index.html.heex:60 +#: lib/mv_web/live/member_live/index.html.heex:63 #, elixir-autogen, elixir-format msgid "First name" msgstr "Vorname" @@ -621,7 +623,7 @@ msgstr "Benutzerdefinierte Feldwerte" msgid "Custom field" msgstr "Benutzerdefiniertes Feld" -#: lib/mv_web/live/custom_field_live/form.ex:115 +#: lib/mv_web/live/custom_field_live/form.ex:117 #, elixir-autogen, elixir-format msgid "Custom field %{action} successfully" msgstr "Benutzerdefiniertes Feld erfolgreich %{action}" @@ -636,7 +638,7 @@ msgstr "Benutzerdefinierter Feldwert erfolgreich %{action}" msgid "Please select a custom field first" msgstr "Bitte wähle zuerst ein Benutzerdefiniertes Feld" -#: lib/mv_web/live/custom_field_live/form.ex:65 +#: lib/mv_web/live/custom_field_live/form.ex:67 #, elixir-autogen, elixir-format msgid "Save Custom field" msgstr "Benutzerdefiniertes Feld speichern" @@ -646,7 +648,7 @@ msgstr "Benutzerdefiniertes Feld speichern" msgid "Save Custom field value" msgstr "Benutzerdefinierten Feldwert speichern" -#: lib/mv_web/live/custom_field_live/form.ex:45 +#: lib/mv_web/live/custom_field_live/form.ex:46 #, elixir-autogen, elixir-format msgid "Use this form to manage custom_field records in your database." msgstr "Verwende dieses Formular, um Benutzerdefinierte Felder in deiner Datenbank zu verwalten." @@ -747,3 +749,12 @@ msgstr "Entverknüpfung geplant" #, elixir-autogen, elixir-format msgid "Failed to link member: %{error}" msgstr "" +#: lib/mv_web/live/custom_field_live/form.ex:64 +#, elixir-autogen, elixir-format +msgid "Show in overview" +msgstr "In der Mitglieder-Übersicht anzeigen" + +#~ #: lib/mv_web/live/custom_field_live/index.ex:97 +#~ #, elixir-autogen, elixir-format +#~ msgid "To confirm deletion, please enter the custom field slug:" +#~ msgstr "Um die Löschung zu bestätigen, gib bitte den Slug des benutzerdefinierten Feldes ein:" diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot index a1ae484..7cf507b 100644 --- a/priv/gettext/default.pot +++ b/priv/gettext/default.pot @@ -11,13 +11,13 @@ msgid "" msgstr "" -#: lib/mv_web/components/core_components.ex:339 +#: lib/mv_web/components/core_components.ex:356 #, elixir-autogen, elixir-format msgid "Actions" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex:200 -#: lib/mv_web/live/user_live/index.html.heex:72 +#: lib/mv_web/live/member_live/index.html.heex:202 +#: lib/mv_web/live/user_live/index.html.heex:65 #, elixir-autogen, elixir-format msgid "Are you sure?" msgstr "" @@ -29,21 +29,21 @@ msgid "Attempting to reconnect" msgstr "" #: lib/mv_web/live/member_live/form.ex:54 -#: lib/mv_web/live/member_live/index.html.heex:145 +#: lib/mv_web/live/member_live/index.html.heex:148 #: 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:202 -#: lib/mv_web/live/user_live/index.html.heex:74 +#: lib/mv_web/live/member_live/index.html.heex:204 +#: lib/mv_web/live/user_live/index.html.heex:67 #, elixir-autogen, elixir-format msgid "Delete" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex:194 -#: lib/mv_web/live/user_live/form.ex:251 -#: lib/mv_web/live/user_live/index.html.heex:66 +#: lib/mv_web/live/member_live/index.html.heex:196 +#: lib/mv_web/live/user_live/form.ex:141 +#: lib/mv_web/live/user_live/index.html.heex:59 #, elixir-autogen, elixir-format msgid "Edit" msgstr "" @@ -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:77 +#: lib/mv_web/live/member_live/index.html.heex:80 #: 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:179 +#: lib/mv_web/live/member_live/index.html.heex:182 #: lib/mv_web/live/member_live/show.ex:56 #, elixir-autogen, elixir-format msgid "Join Date" @@ -88,8 +88,8 @@ msgstr "" msgid "New Member" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex:191 -#: lib/mv_web/live/user_live/index.html.heex:63 +#: lib/mv_web/live/member_live/index.html.heex:193 +#: lib/mv_web/live/user_live/index.html.heex:56 #, elixir-autogen, elixir-format msgid "Show" 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:111 +#: lib/mv_web/live/member_live/index.html.heex:114 #: lib/mv_web/live/member_live/show.ex:61 #, elixir-autogen, elixir-format msgid "House Number" @@ -141,14 +141,14 @@ msgid "Paid" msgstr "" #: lib/mv_web/live/member_live/form.ex:50 -#: lib/mv_web/live/member_live/index.html.heex:162 +#: lib/mv_web/live/member_live/index.html.heex:165 #: 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:128 +#: lib/mv_web/live/member_live/index.html.heex:131 #: lib/mv_web/live/member_live/show.ex:62 #, elixir-autogen, elixir-format msgid "Postal Code" @@ -159,7 +159,7 @@ msgstr "" msgid "Save Member" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:64 +#: lib/mv_web/live/custom_field_live/form.ex:66 #: lib/mv_web/live/custom_field_value_live/form.ex:74 #: lib/mv_web/live/member_live/form.ex:79 #: lib/mv_web/live/user_live/form.ex:234 @@ -168,7 +168,7 @@ msgid "Saving..." msgstr "" #: lib/mv_web/live/member_live/form.ex:55 -#: lib/mv_web/live/member_live/index.html.heex:94 +#: lib/mv_web/live/member_live/index.html.heex:97 #: lib/mv_web/live/member_live/show.ex:60 #, elixir-autogen, elixir-format msgid "Street" @@ -184,6 +184,7 @@ msgstr "" msgid "Id" msgstr "" +#: lib/mv_web/live/member_live/index/formatter.ex:65 #: lib/mv_web/live/member_live/show.ex:53 #, elixir-autogen, elixir-format msgid "No" @@ -199,19 +200,20 @@ msgstr "" msgid "This is a member record from your database." msgstr "" +#: lib/mv_web/live/member_live/index/formatter.ex:64 #: lib/mv_web/live/member_live/show.ex:53 #, elixir-autogen, elixir-format msgid "Yes" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:108 +#: lib/mv_web/live/custom_field_live/form.ex:110 #: lib/mv_web/live/custom_field_value_live/form.ex:233 #: lib/mv_web/live/member_live/form.ex:138 #, elixir-autogen, elixir-format msgid "create" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:109 +#: lib/mv_web/live/custom_field_live/form.ex:111 #: lib/mv_web/live/custom_field_value_live/form.ex:234 #: lib/mv_web/live/member_live/form.ex:139 #, elixir-autogen, elixir-format @@ -253,7 +255,7 @@ msgstr "" msgid "Your password has successfully been reset" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:67 +#: lib/mv_web/live/custom_field_live/form.ex:69 #: lib/mv_web/live/custom_field_live/index.ex:120 #: lib/mv_web/live/custom_field_value_live/form.ex:77 #: lib/mv_web/live/member_live/form.ex:82 @@ -267,7 +269,7 @@ msgstr "" msgid "Choose a member" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:60 +#: lib/mv_web/live/custom_field_live/form.ex:61 #, elixir-autogen, elixir-format msgid "Description" msgstr "" @@ -287,7 +289,7 @@ msgstr "" msgid "ID" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:61 +#: lib/mv_web/live/custom_field_live/form.ex:62 #, elixir-autogen, elixir-format msgid "Immutable" msgstr "" @@ -309,13 +311,13 @@ msgid "Member" msgstr "" #: lib/mv_web/components/layouts/navbar.ex:19 -#: lib/mv_web/live/member_live/index.ex:39 +#: lib/mv_web/live/member_live/index.ex:57 #: lib/mv_web/live/member_live/index.html.heex:3 #, elixir-autogen, elixir-format msgid "Members" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:50 +#: lib/mv_web/live/custom_field_live/form.ex:51 #, elixir-autogen, elixir-format msgid "Name" msgstr "" @@ -358,17 +360,17 @@ msgstr "" msgid "Profil" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:62 +#: lib/mv_web/live/custom_field_live/form.ex:63 #, elixir-autogen, elixir-format msgid "Required" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex:34 +#: lib/mv_web/live/member_live/index.html.heex:37 #, elixir-autogen, elixir-format msgid "Select all members" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex:48 +#: lib/mv_web/live/member_live/index.html.heex:51 #, elixir-autogen, elixir-format msgid "Select member" msgstr "" @@ -414,7 +416,7 @@ msgstr "" msgid "Value" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:55 +#: lib/mv_web/live/custom_field_live/form.ex:56 #, elixir-autogen, elixir-format msgid "Value type" msgstr "" @@ -570,7 +572,7 @@ msgstr "" msgid "Click to sort" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex:60 +#: lib/mv_web/live/member_live/index.html.heex:63 #, elixir-autogen, elixir-format msgid "First name" msgstr "" @@ -622,7 +624,7 @@ msgstr "" msgid "Custom field" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:115 +#: lib/mv_web/live/custom_field_live/form.ex:117 #, elixir-autogen, elixir-format msgid "Custom field %{action} successfully" msgstr "" @@ -637,7 +639,7 @@ msgstr "" msgid "Please select a custom field first" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:65 +#: lib/mv_web/live/custom_field_live/form.ex:67 #, elixir-autogen, elixir-format msgid "Save Custom field" msgstr "" @@ -647,7 +649,7 @@ msgstr "" msgid "Save Custom field value" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:45 +#: lib/mv_web/live/custom_field_live/form.ex:46 #, elixir-autogen, elixir-format msgid "Use this form to manage custom_field records in your database." msgstr "" @@ -699,52 +701,7 @@ msgstr "" msgid "To confirm deletion, please enter this text:" msgstr "" -#: lib/mv_web/live/user_live/form.ex:210 +#: lib/mv_web/live/custom_field_live/form.ex:64 #, elixir-autogen, elixir-format -msgid "A member with this email already exists. To link with a different member, please change one of the email addresses first." -msgstr "" - -#: lib/mv_web/live/user_live/form.ex:185 -#, elixir-autogen, elixir-format -msgid "Available members" -msgstr "" - -#: lib/mv_web/live/user_live/form.ex:152 -#, elixir-autogen, elixir-format -msgid "Member will be unlinked when you save. Cannot select new member until saved." -msgstr "" - -#: lib/mv_web/live/user_live/form.ex:226 -#, elixir-autogen, elixir-format -msgid "Save to confirm linking." -msgstr "" - -#: lib/mv_web/live/user_live/form.ex:169 -#, elixir-autogen, elixir-format -msgid "Search for a member to link..." -msgstr "" - -#: lib/mv_web/live/user_live/form.ex:173 -#, elixir-autogen, elixir-format -msgid "Search for member to link" -msgstr "" - -#: lib/mv_web/live/user_live/form.ex:223 -#, elixir-autogen, elixir-format -msgid "Selected" -msgstr "" - -#: lib/mv_web/live/user_live/form.ex:143 -#, elixir-autogen, elixir-format -msgid "Unlink Member" -msgstr "" - -#: lib/mv_web/live/user_live/form.ex:152 -#, elixir-autogen, elixir-format -msgid "Unlinking scheduled" -msgstr "" - -#: lib/mv_web/live/user_live/form.ex:342 -#, elixir-autogen, elixir-format -msgid "Failed to link member: %{error}" +msgid "Show in overview" msgstr "" diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po index 28339fc..ed38b0e 100644 --- a/priv/gettext/en/LC_MESSAGES/default.po +++ b/priv/gettext/en/LC_MESSAGES/default.po @@ -11,13 +11,13 @@ msgstr "" "Language: en\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" -#: lib/mv_web/components/core_components.ex:339 +#: lib/mv_web/components/core_components.ex:356 #, elixir-autogen, elixir-format msgid "Actions" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex:200 -#: lib/mv_web/live/user_live/index.html.heex:72 +#: lib/mv_web/live/member_live/index.html.heex:202 +#: lib/mv_web/live/user_live/index.html.heex:65 #, elixir-autogen, elixir-format msgid "Are you sure?" msgstr "" @@ -29,21 +29,21 @@ msgid "Attempting to reconnect" msgstr "" #: lib/mv_web/live/member_live/form.ex:54 -#: lib/mv_web/live/member_live/index.html.heex:145 +#: lib/mv_web/live/member_live/index.html.heex:148 #: 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:202 -#: lib/mv_web/live/user_live/index.html.heex:74 +#: lib/mv_web/live/member_live/index.html.heex:204 +#: lib/mv_web/live/user_live/index.html.heex:67 #, elixir-autogen, elixir-format msgid "Delete" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex:194 -#: lib/mv_web/live/user_live/form.ex:251 -#: lib/mv_web/live/user_live/index.html.heex:66 +#: lib/mv_web/live/member_live/index.html.heex:196 +#: lib/mv_web/live/user_live/form.ex:141 +#: lib/mv_web/live/user_live/index.html.heex:59 #, elixir-autogen, elixir-format msgid "Edit" msgstr "" @@ -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:77 +#: lib/mv_web/live/member_live/index.html.heex:80 #: 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:179 +#: lib/mv_web/live/member_live/index.html.heex:182 #: lib/mv_web/live/member_live/show.ex:56 #, elixir-autogen, elixir-format msgid "Join Date" @@ -88,8 +88,8 @@ msgstr "" msgid "New Member" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex:191 -#: lib/mv_web/live/user_live/index.html.heex:63 +#: lib/mv_web/live/member_live/index.html.heex:193 +#: lib/mv_web/live/user_live/index.html.heex:56 #, elixir-autogen, elixir-format msgid "Show" 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:111 +#: lib/mv_web/live/member_live/index.html.heex:114 #: lib/mv_web/live/member_live/show.ex:61 #, elixir-autogen, elixir-format msgid "House Number" @@ -141,14 +141,14 @@ msgid "Paid" msgstr "" #: lib/mv_web/live/member_live/form.ex:50 -#: lib/mv_web/live/member_live/index.html.heex:162 +#: lib/mv_web/live/member_live/index.html.heex:165 #: 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:128 +#: lib/mv_web/live/member_live/index.html.heex:131 #: lib/mv_web/live/member_live/show.ex:62 #, elixir-autogen, elixir-format msgid "Postal Code" @@ -159,7 +159,7 @@ msgstr "" msgid "Save Member" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:64 +#: lib/mv_web/live/custom_field_live/form.ex:66 #: lib/mv_web/live/custom_field_value_live/form.ex:74 #: lib/mv_web/live/member_live/form.ex:79 #: lib/mv_web/live/user_live/form.ex:234 @@ -168,7 +168,7 @@ msgid "Saving..." msgstr "" #: lib/mv_web/live/member_live/form.ex:55 -#: lib/mv_web/live/member_live/index.html.heex:94 +#: lib/mv_web/live/member_live/index.html.heex:97 #: lib/mv_web/live/member_live/show.ex:60 #, elixir-autogen, elixir-format msgid "Street" @@ -184,6 +184,7 @@ msgstr "" msgid "Id" msgstr "" +#: lib/mv_web/live/member_live/index/formatter.ex:65 #: lib/mv_web/live/member_live/show.ex:53 #, elixir-autogen, elixir-format msgid "No" @@ -199,19 +200,20 @@ msgstr "" msgid "This is a member record from your database." msgstr "" +#: lib/mv_web/live/member_live/index/formatter.ex:64 #: lib/mv_web/live/member_live/show.ex:53 #, elixir-autogen, elixir-format msgid "Yes" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:108 +#: lib/mv_web/live/custom_field_live/form.ex:110 #: lib/mv_web/live/custom_field_value_live/form.ex:233 #: lib/mv_web/live/member_live/form.ex:138 #, elixir-autogen, elixir-format msgid "create" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:109 +#: lib/mv_web/live/custom_field_live/form.ex:111 #: lib/mv_web/live/custom_field_value_live/form.ex:234 #: lib/mv_web/live/member_live/form.ex:139 #, elixir-autogen, elixir-format @@ -253,7 +255,7 @@ msgstr "" msgid "Your password has successfully been reset" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:67 +#: lib/mv_web/live/custom_field_live/form.ex:69 #: lib/mv_web/live/custom_field_live/index.ex:120 #: lib/mv_web/live/custom_field_value_live/form.ex:77 #: lib/mv_web/live/member_live/form.ex:82 @@ -267,7 +269,7 @@ msgstr "" msgid "Choose a member" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:60 +#: lib/mv_web/live/custom_field_live/form.ex:61 #, elixir-autogen, elixir-format msgid "Description" msgstr "" @@ -287,7 +289,7 @@ msgstr "" msgid "ID" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:61 +#: lib/mv_web/live/custom_field_live/form.ex:62 #, elixir-autogen, elixir-format msgid "Immutable" msgstr "" @@ -309,13 +311,13 @@ msgid "Member" msgstr "" #: lib/mv_web/components/layouts/navbar.ex:19 -#: lib/mv_web/live/member_live/index.ex:39 +#: lib/mv_web/live/member_live/index.ex:57 #: lib/mv_web/live/member_live/index.html.heex:3 #, elixir-autogen, elixir-format msgid "Members" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:50 +#: lib/mv_web/live/custom_field_live/form.ex:51 #, elixir-autogen, elixir-format msgid "Name" msgstr "" @@ -358,17 +360,17 @@ msgstr "" msgid "Profil" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:62 +#: lib/mv_web/live/custom_field_live/form.ex:63 #, elixir-autogen, elixir-format msgid "Required" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex:34 +#: lib/mv_web/live/member_live/index.html.heex:37 #, elixir-autogen, elixir-format msgid "Select all members" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex:48 +#: lib/mv_web/live/member_live/index.html.heex:51 #, elixir-autogen, elixir-format msgid "Select member" msgstr "" @@ -414,7 +416,7 @@ msgstr "" msgid "Value" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:55 +#: lib/mv_web/live/custom_field_live/form.ex:56 #, elixir-autogen, elixir-format msgid "Value type" msgstr "" @@ -570,7 +572,7 @@ msgstr "" msgid "Click to sort" msgstr "" -#: lib/mv_web/live/member_live/index.html.heex:60 +#: lib/mv_web/live/member_live/index.html.heex:63 #, elixir-autogen, elixir-format, fuzzy msgid "First name" msgstr "" @@ -622,7 +624,7 @@ msgstr "" msgid "Custom field" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:115 +#: lib/mv_web/live/custom_field_live/form.ex:117 #, elixir-autogen, elixir-format msgid "Custom field %{action} successfully" msgstr "" @@ -637,7 +639,7 @@ msgstr "" msgid "Please select a custom field first" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:65 +#: lib/mv_web/live/custom_field_live/form.ex:67 #, elixir-autogen, elixir-format msgid "Save Custom field" msgstr "" @@ -647,7 +649,7 @@ msgstr "" msgid "Save Custom field value" msgstr "" -#: lib/mv_web/live/custom_field_live/form.ex:45 +#: lib/mv_web/live/custom_field_live/form.ex:46 #, elixir-autogen, elixir-format, fuzzy msgid "Use this form to manage custom_field records in your database." msgstr "" @@ -748,3 +750,12 @@ msgstr "" #, elixir-autogen, elixir-format msgid "Failed to link member: %{error}" msgstr "" +#: lib/mv_web/live/custom_field_live/form.ex:64 +#, elixir-autogen, elixir-format +msgid "Show in overview" +msgstr "" + +#~ #: lib/mv_web/live/custom_field_live/index.ex:97 +#~ #, elixir-autogen, elixir-format +#~ msgid "To confirm deletion, please enter the custom field slug:" +#~ msgstr ""