Compare commits
8 commits
1819a1e2d1
...
62d472cee6
| Author | SHA1 | Date | |
|---|---|---|---|
| 62d472cee6 | |||
| 814b84c120 | |||
| a67a2780b7 | |||
| eaaa2f37dc | |||
| 56cfa01711 | |||
| e30778d286 | |||
| afd3cc88dc | |||
| 88bed75ac9 |
12 changed files with 474 additions and 365 deletions
19
CHANGELOG.md
Normal file
19
CHANGELOG.md
Normal file
|
|
@ -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
|
||||
|
||||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,33 +0,0 @@
|
|||
defmodule Mv.Accounts.DebugChangesetTest do
|
||||
use Mv.DataCase, async: true
|
||||
|
||||
alias Mv.Accounts
|
||||
alias Mv.Membership
|
||||
|
||||
test "debug: what's in the changeset when linking with same email" do
|
||||
# Create member
|
||||
{:ok, member} =
|
||||
Membership.create_member(%{
|
||||
first_name: "Emma",
|
||||
last_name: "Davis",
|
||||
email: "emma@example.com"
|
||||
})
|
||||
|
||||
IO.puts("\n=== MEMBER CREATED ===")
|
||||
IO.puts("Member ID: #{member.id}")
|
||||
IO.puts("Member Email: #{member.email}")
|
||||
|
||||
# Try to create user with same email and link
|
||||
IO.puts("\n=== ATTEMPTING TO CREATE USER WITH LINK ===")
|
||||
|
||||
# Let's intercept the validation to see what's in the changeset
|
||||
result =
|
||||
Accounts.create_user(%{
|
||||
email: "emma@example.com",
|
||||
member: %{id: member.id}
|
||||
})
|
||||
|
||||
IO.puts("\n=== RESULT ===")
|
||||
IO.inspect(result, label: "Result")
|
||||
end
|
||||
end
|
||||
|
|
@ -5,7 +5,7 @@ defmodule Mv.Accounts.UserMemberLinkingTest do
|
|||
Tests the complete workflow of linking and unlinking members to users,
|
||||
including email synchronization and validation rules.
|
||||
"""
|
||||
use Mv.DataCase, async: true
|
||||
use Mv.DataCase, async: false
|
||||
alias Mv.Accounts
|
||||
alias Mv.Membership
|
||||
|
||||
|
|
|
|||
|
|
@ -1,48 +0,0 @@
|
|||
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
|
||||
|
|
@ -1,52 +0,0 @@
|
|||
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
|
||||
149
test/mv_web/user_live/form_member_dropdown_test.exs
Normal file
149
test/mv_web/user_live/form_member_dropdown_test.exs
Normal file
|
|
@ -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
|
||||
112
test/mv_web/user_live/form_member_search_test.exs
Normal file
112
test/mv_web/user_live/form_member_search_test.exs
Normal file
|
|
@ -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
|
||||
|
|
@ -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
|
||||
96
test/support/fixtures.ex
Normal file
96
test/support/fixtures.ex
Normal file
|
|
@ -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
|
||||
Loading…
Add table
Add a link
Reference in a new issue