feat: add user to member linking
Some checks failed
continuous-integration/drone/push Build is failing

This commit is contained in:
Moritz 2025-11-13 22:31:32 +01:00
parent 21ec86839a
commit 1819a1e2d1
Signed by: moritz
GPG key ID: 1020A035E5DD0824
22 changed files with 2061 additions and 45 deletions

View file

@ -0,0 +1,48 @@
defmodule MvWeb.UserLive.FormDebug2Test do
use Mv.DataCase, async: true
describe "direct ash query test" do
test "check if available_for_linking works in LiveView context" do
# Create an unlinked member
{:ok, member} =
Mv.Membership.create_member(%{
first_name: "Jane",
last_name: "Smith",
email: "jane@example.com"
})
IO.puts("\n=== Created member: #{inspect(member.id)} ===")
# Try the same query as in the LiveView
user_email_str = "user@example.com"
search_query_str = nil
IO.puts("\n=== Calling Ash.read with domain: Mv.Membership ===")
result =
Ash.read(Mv.Membership.Member,
domain: Mv.Membership,
action: :available_for_linking,
arguments: %{user_email: user_email_str, search_query: search_query_str}
)
IO.puts("Result: #{inspect(result)}")
case result do
{:ok, members} ->
IO.puts("\n✓ Query succeeded, found #{length(members)} members")
Enum.each(members, fn m ->
IO.puts(" - #{m.first_name} #{m.last_name} (#{m.email})")
end)
# Apply filter
filtered = Mv.Membership.Member.filter_by_email_match(members, user_email_str)
IO.puts("\n✓ After filter_by_email_match: #{length(filtered)} members")
{:error, error} ->
IO.puts("\n✗ Query failed: #{inspect(error)}")
end
end
end
end

View file

@ -0,0 +1,52 @@
defmodule MvWeb.UserLive.FormDebugTest do
use MvWeb.ConnCase, async: true
import Phoenix.LiveViewTest
# Helper to setup authenticated connection and live view
defp setup_live_view(conn, path) do
conn = conn_with_oidc_user(conn, %{email: "admin@example.com"})
live(conn, path)
end
describe "debug member loading" do
test "check if members are loaded on mount", %{conn: conn} do
# Create an 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"})
# Mount the form
{:ok, view, html} = setup_live_view(conn, "/users/#{user.id}/edit")
# Debug: Check what's in the HTML
IO.puts("\n=== HTML OUTPUT ===")
IO.puts(html)
IO.puts("\n=== END HTML ===")
# Check socket assigns
IO.puts("\n=== SOCKET ASSIGNS ===")
assigns = :sys.get_state(view.pid).socket.assigns
IO.puts("available_members: #{inspect(assigns[:available_members])}")
IO.puts("show_member_dropdown: #{inspect(assigns[:show_member_dropdown])}")
IO.puts("member_search_query: #{inspect(assigns[:member_search_query])}")
IO.puts("user.member: #{inspect(assigns[:user].member)}")
IO.puts("\n=== END ASSIGNS ===")
# Try to find the dropdown
assert has_element?(view, "input[name='member_search']")
# Check if member is in the dropdown
if has_element?(view, "div[data-member-id='#{member.id}']") do
IO.puts("\n✓ Member found in dropdown")
else
IO.puts("\n✗ Member NOT found in dropdown")
end
end
end
end

View file

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

View file

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

View file

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