Compare commits

...

21 commits

Author SHA1 Message Date
8104451d35 format and linting: reduced complexity of function
All checks were successful
continuous-integration/drone/push Build is passing
2025-10-30 16:42:19 +01:00
bb362e1636 formating 2025-10-30 16:42:19 +01:00
41e3a52482 test: updated tests 2025-10-30 16:42:19 +01:00
b71df98ba2 fix: catch invalid sorting order 2025-10-30 16:42:19 +01:00
eb42b9fe0a fix: keep search term on refresh and enter 2025-10-30 16:42:19 +01:00
85e1f370f6 fix: keep search term while sorting 2025-10-30 16:42:19 +01:00
9d98ec2494 formatting 2025-10-30 16:42:19 +01:00
017ca8b32c chore: updated translation 2025-10-30 16:42:19 +01:00
3cfae95b1e test: added tests 2025-10-30 16:42:19 +01:00
c3502a326e docs: formatting, docs and accessibility fix 2025-10-30 16:42:19 +01:00
d9e48a37d2 feat: sort header for members list 2025-10-30 16:42:19 +01:00
687d653fb7 Merge pull request 'sync email between user and member closes #167' (#181) from feature/email-sync into main
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #181
Reviewed-by: carla <carla@noreply.git.local-it.org>
2025-10-30 16:25:34 +01:00
899039b3ee
add docs
All checks were successful
continuous-integration/drone/push Build is passing
2025-10-23 13:13:29 +02:00
37fcc26b22
add seed test 2025-10-23 13:13:29 +02:00
1495ef4592
fix validation behaviour 2025-10-23 13:13:29 +02:00
001fca1d16
refactor: email sync changes 2025-10-23 13:13:28 +02:00
2693f67d33
refactor: email validations 2025-10-23 13:13:28 +02:00
7522724945
refactor: email sync changes 2025-10-23 13:13:28 +02:00
39afaf3999
feat: email uniqueness constraint between user and member 2025-10-23 13:13:27 +02:00
5a0a261cd6
add action changes for email sync 2025-10-23 13:13:27 +02:00
91c5e17994
email sync tests 2025-10-23 13:13:27 +02:00
27 changed files with 2391 additions and 205 deletions

49
docs/email-sync.md Normal file
View file

@ -0,0 +1,49 @@
## Core Rules
1. **User.email is source of truth** - Always overrides member email when linking
2. **DB constraints** - Prevent duplicates within same table (users.email, members.email)
3. **Custom validations** - Prevent cross-table conflicts only for linked entities
4. **Sync is bidirectional**: User ↔ Member (but User always wins on link)
---
## Decision Tree
```
Action: Create/Update/Link Entity with Email X
├─ Does Email X violate DB constraint (same table)?
│ └─ YES → ❌ FAIL (two users or two members with same email)
├─ Is Entity currently linked? (or being linked?)
│ │
│ ├─ NO (unlinked entity)
│ │ └─ ✅ SUCCESS (no custom validation)
│ │
│ └─ YES (linked or linking)
│ │
│ ├─ Action: Update Linked User Email
│ │ ├─ Email used by other member? → ❌ FAIL (validation)
│ │ └─ Email unique? → ✅ SUCCESS + sync to member
│ │
│ ├─ Action: Update Linked Member Email
│ │ ├─ Email used by other user? → ❌ FAIL (validation)
│ │ └─ Email unique? → ✅ SUCCESS + sync to user
│ │
│ ├─ Action: Link User to Member (both directions)
│ │ ├─ User email used by other member? → ❌ FAIL (validation)
│ │ └─ Otherwise → ✅ SUCCESS + override member email
```
## Sync Triggers
| Action | Sync Direction | When |
|--------|---------------|------|
| Update linked user email | User → Member | Email changed |
| Update linked member email | Member → User | Email changed |
| Link user to member | User → Member | Always (override) |
| Link member to user | User → Member | Always (override) |
| Unlink | None | Emails stay as-is |

View file

@ -66,14 +66,17 @@ defmodule Mv.Accounts.User do
end end
actions do actions do
# Default actions kept for framework/tooling integration: # Default actions for framework/tooling integration:
# - :create -> Used by AshAdmin's generated "Create" UI and by generic
# AshPhoenix helpers that assume a default create action.
# It does NOT manage the :member relationship. For admin
# flows that may link an existing member, use :create_user.
# - :read -> Standard read used across the app and by admin tooling. # - :read -> Standard read used across the app and by admin tooling.
# - :destroy-> Standard delete used by admin tooling and maintenance tasks. # - :destroy-> Standard delete used by admin tooling and maintenance tasks.
defaults [:read, :create, :destroy] #
# NOTE: :create is INTENTIONALLY excluded from defaults!
# Using a default :create would bypass email-synchronization logic.
# Always use one of these explicit create actions instead:
# - :create_user (for manual user creation with optional member link)
# - :register_with_password (for password-based registration)
# - :register_with_rauthy (for OIDC-based registration)
defaults [:read, :destroy]
# Primary generic update action: # Primary generic update action:
# - Selected by AshAdmin's generated "Edit" UI and generic AshPhoenix # - Selected by AshAdmin's generated "Edit" UI and generic AshPhoenix
@ -89,6 +92,12 @@ defmodule Mv.Accounts.User do
# cannot be executed atomically. These validations need to query the database and perform # cannot be executed atomically. These validations need to query the database and perform
# complex checks that are not supported in atomic operations. # complex checks that are not supported in atomic operations.
require_atomic? false require_atomic? false
# Sync email changes to linked member (User → Member)
# Only runs when email is being changed
change Mv.EmailSync.Changes.SyncUserEmailToMember do
where [changing(:email)]
end
end end
create :create_user do create :create_user do
@ -111,6 +120,9 @@ defmodule Mv.Accounts.User do
# If no member provided, that's fine (optional relationship) # If no member provided, that's fine (optional relationship)
on_missing: :ignore on_missing: :ignore
) )
# Sync user email to member when linking (User → Member)
change Mv.EmailSync.Changes.SyncUserEmailToMember
end end
update :update_user do update :update_user do
@ -137,6 +149,12 @@ defmodule Mv.Accounts.User do
# If no member provided, remove existing relationship (allows member removal) # If no member provided, remove existing relationship (allows member removal)
on_missing: :unrelate on_missing: :unrelate
) )
# Sync email changes and handle linking (User → Member)
# Runs when email OR member relationship changes
change Mv.EmailSync.Changes.SyncUserEmailToMember do
where any([changing(:email), changing(:member)])
end
end end
# Admin action for direct password changes in admin panel # Admin action for direct password changes in admin panel
@ -185,6 +203,9 @@ defmodule Mv.Accounts.User do
|> Ash.Changeset.change_attribute(:email, user_info["preferred_username"]) |> Ash.Changeset.change_attribute(:email, user_info["preferred_username"])
|> Ash.Changeset.change_attribute(:oidc_id, user_info["sub"] || user_info["id"]) |> Ash.Changeset.change_attribute(:oidc_id, user_info["sub"] || user_info["id"])
end end
# Sync user email to member when linking (User → Member)
change Mv.EmailSync.Changes.SyncUserEmailToMember
end end
end end
@ -195,6 +216,10 @@ defmodule Mv.Accounts.User do
where: [action_is([:register_with_password, :admin_set_password])], where: [action_is([:register_with_password, :admin_set_password])],
message: "must have length of at least 8" message: "must have length of at least 8"
# Email uniqueness check for all actions that change the email attribute
# Validates that user email is not already used by another (unlinked) member
validate Mv.Accounts.User.Validations.EmailNotUsedByOtherMember
# Email validation with EctoCommons.EmailValidator (same as Member) # Email validation with EctoCommons.EmailValidator (same as Member)
# This ensures consistency between User and Member email validation # This ensures consistency between User and Member email validation
validate fn changeset, _ -> validate fn changeset, _ ->
@ -255,6 +280,13 @@ defmodule Mv.Accounts.User do
attributes do attributes do
uuid_primary_key :id uuid_primary_key :id
# IMPORTANT: Email Synchronization
# When user and member are linked, emails are automatically synced bidirectionally.
# User.email is the source of truth - when a link is established, member.email
# is overridden to match user.email. Subsequent changes to either email will
# sync to the other resource.
# See: Mv.EmailSync.Changes.SyncUserEmailToMember
# Mv.EmailSync.Changes.SyncMemberEmailToUser
attribute :email, :ci_string do attribute :email, :ci_string do
allow_nil? false allow_nil? false
public? true public? true

View file

@ -48,6 +48,12 @@ defmodule Mv.Membership.Member do
# If no user provided, that's fine (optional relationship) # If no user provided, that's fine (optional relationship)
on_missing: :ignore on_missing: :ignore
) )
# Sync user email to member when linking (User → Member)
# Only runs when user relationship is being changed
change Mv.EmailSync.Changes.SyncUserEmailToMember do
where [changing(:user)]
end
end end
update :update_member do update :update_member do
@ -89,6 +95,18 @@ defmodule Mv.Membership.Member do
# If no user provided, remove existing relationship (allows user removal) # If no user provided, remove existing relationship (allows user removal)
on_missing: :unrelate on_missing: :unrelate
) )
# Sync member email to user when email changes (Member → User)
# Only runs when email is being changed
change Mv.EmailSync.Changes.SyncMemberEmailToUser do
where [changing(:email)]
end
# Sync user email to member when linking (User → Member)
# Only runs when user relationship is being changed
change Mv.EmailSync.Changes.SyncUserEmailToMember do
where [changing(:user)]
end
end end
end end
@ -100,6 +118,10 @@ defmodule Mv.Membership.Member do
validate present(:last_name) validate present(:last_name)
validate present(:email) validate present(:email)
# Email uniqueness check for all actions that change the email attribute
# Validates that member email is not already used by another (unlinked) user
validate Mv.Membership.Member.Validations.EmailNotUsedByOtherUser
# Prevent linking to a user that already has a member # Prevent linking to a user that already has a member
# This validation prevents "stealing" users from other members by checking # This validation prevents "stealing" users from other members by checking
# if the target user is already linked to a different member # if the target user is already linked to a different member
@ -189,6 +211,13 @@ defmodule Mv.Membership.Member do
constraints min_length: 1 constraints min_length: 1
end end
# IMPORTANT: Email Synchronization
# When member and user are linked, emails are automatically synced bidirectionally.
# User.email is the source of truth - when a link is established, member.email
# is overridden to match user.email. Subsequent changes to either email will
# sync to the other resource.
# See: Mv.EmailSync.Changes.SyncUserEmailToMember
# Mv.EmailSync.Changes.SyncMemberEmailToUser
attribute :email, :string do attribute :email, :string do
allow_nil? false allow_nil? false
constraints min_length: 5, max_length: 254 constraints min_length: 5, max_length: 254

View file

@ -0,0 +1,60 @@
defmodule Mv.Accounts.User.Validations.EmailNotUsedByOtherMember do
@moduledoc """
Validates that the user's email is not already used by another member.
Only validates when:
- User is already linked to a member (member_id != nil) AND email is changing
- User is being linked to a member (member relationship is changing)
This allows creating users with the same email as unlinked members.
"""
use Ash.Resource.Validation
@impl true
def validate(changeset, _opts, _context) do
email_changing? = Ash.Changeset.changing_attribute?(changeset, :email)
member_changing? = Ash.Changeset.changing_relationship?(changeset, :member)
member_id = Ash.Changeset.get_attribute(changeset, :member_id)
is_linked? = not is_nil(member_id)
# Only validate if:
# 1. User is linked AND email is changing
# 2. User is being linked/unlinked (member relationship changing)
should_validate? = (is_linked? and email_changing?) or member_changing?
if should_validate? do
case Ash.Changeset.fetch_change(changeset, :email) do
{:ok, new_email} ->
check_email_uniqueness(new_email, member_id)
:error ->
# No email change, get current email
current_email = Ash.Changeset.get_attribute(changeset, :email)
check_email_uniqueness(current_email, member_id)
end
else
:ok
end
end
defp check_email_uniqueness(email, exclude_member_id) do
query =
Mv.Membership.Member
|> Ash.Query.filter(email == ^to_string(email))
|> maybe_exclude_id(exclude_member_id)
case Ash.read(query) do
{:ok, []} ->
:ok
{:ok, _} ->
{:error, field: :email, message: "is already used by another member", value: email}
{:error, _} ->
:ok
end
end
defp maybe_exclude_id(query, nil), do: query
defp maybe_exclude_id(query, id), do: Ash.Query.filter(query, id != ^id)
end

View file

@ -0,0 +1,37 @@
defmodule Mv.EmailSync.Changes.SyncMemberEmailToUser do
@moduledoc """
Synchronizes Member.email User.email
Trigger conditions are configured in resources via `where` clauses:
- Member resource: Use `where: [changing(:email)]`
Used by Member resource for bidirectional email sync.
"""
use Ash.Resource.Change
alias Mv.EmailSync.{Helpers, Loader}
@impl true
def change(changeset, _opts, context) do
# Only recursion protection needed - trigger logic is in `where` clauses
if Map.get(context, :syncing_email, false) do
changeset
else
sync_email(changeset)
end
end
defp sync_email(changeset) do
new_email = Ash.Changeset.get_attribute(changeset, :email)
Ash.Changeset.around_transaction(changeset, fn cs, callback ->
result = callback.(cs)
with {:ok, member} <- Helpers.extract_record(result),
linked_user <- Loader.get_linked_user(member) do
Helpers.sync_email_to_linked_record(result, linked_user, new_email)
else
_ -> result
end
end)
end
end

View file

@ -0,0 +1,62 @@
defmodule Mv.EmailSync.Changes.SyncUserEmailToMember do
@moduledoc """
Synchronizes User.email Member.email
User.email is always the source of truth.
Trigger conditions are configured in resources via `where` clauses:
- User resource: Use `where: [changing(:email)]` or `where: any([changing(:email), changing(:member)])`
- Member resource: Use `where: [changing(:user)]`
Can be used by both User and Member resources.
"""
use Ash.Resource.Change
alias Mv.EmailSync.{Helpers, Loader}
@impl true
def change(changeset, _opts, context) do
# Only recursion protection needed - trigger logic is in `where` clauses
if Map.get(context, :syncing_email, false) do
changeset
else
sync_email(changeset)
end
end
defp sync_email(changeset) do
Ash.Changeset.around_transaction(changeset, fn cs, callback ->
result = callback.(cs)
with {:ok, record} <- Helpers.extract_record(result),
{:ok, user, member} <- get_user_and_member(record) do
# When called from Member-side, we need to update the member in the result
# When called from User-side, we update the linked member in DB only
case record do
%Mv.Membership.Member{} ->
# Member-side: Override member email in result with user email
Helpers.override_with_linked_email(result, user.email)
%Mv.Accounts.User{} ->
# User-side: Sync user email to linked member in DB
Helpers.sync_email_to_linked_record(result, member, user.email)
end
else
_ -> result
end
end)
end
# Retrieves user and member - works for both resource types
defp get_user_and_member(%Mv.Accounts.User{} = user) do
case Loader.get_linked_member(user) do
nil -> {:error, :no_member}
member -> {:ok, user, member}
end
end
defp get_user_and_member(%Mv.Membership.Member{} = member) do
case Loader.load_linked_user!(member) do
{:ok, user} -> {:ok, user, member}
error -> error
end
end
end

View file

@ -0,0 +1,93 @@
defmodule Mv.EmailSync.Helpers do
@moduledoc """
Shared helper functions for email synchronization between User and Member.
Handles the complexity of `around_transaction` callback results and
provides clean abstractions for email updates within transactions.
"""
require Logger
import Ecto.Changeset
@doc """
Extracts the record from an Ash action result.
Handles both 2-tuple `{:ok, record}` and 4-tuple
`{:ok, record, changeset, notifications}` patterns.
"""
def extract_record({:ok, record, _changeset, _notifications}), do: {:ok, record}
def extract_record({:ok, record}), do: {:ok, record}
def extract_record({:error, _} = error), do: error
@doc """
Updates the result with a new record while preserving the original structure.
If the original result was a 4-tuple, returns a 4-tuple with the updated record.
If it was a 2-tuple, returns a 2-tuple with the updated record.
"""
def update_result_record({:ok, _old_record, changeset, notifications}, new_record) do
{:ok, new_record, changeset, notifications}
end
def update_result_record({:ok, _old_record}, new_record) do
{:ok, new_record}
end
@doc """
Updates an email field directly via Ecto within the current transaction.
This bypasses Ash's action system to ensure the update happens in the
same database transaction as the parent action.
"""
def update_email_via_ecto(record, new_email) do
record
|> cast(%{email: to_string(new_email)}, [:email])
|> Mv.Repo.update()
end
@doc """
Synchronizes email to a linked record if it exists.
Returns the original result unchanged, or an error if sync fails.
"""
def sync_email_to_linked_record(result, linked_record, new_email) do
with {:ok, _source} <- extract_record(result),
record when not is_nil(record) <- linked_record,
{:ok, _updated} <- update_email_via_ecto(record, new_email) do
# Successfully synced - return original result unchanged
result
else
nil ->
# No linked record - return original result
result
{:error, error} ->
# Sync failed - log and propagate error to rollback transaction
Logger.error("Email sync failed: #{inspect(error)}")
{:error, error}
end
end
@doc """
Overrides the record's email with the linked email if emails differ.
Returns updated result with new record, or original result if no update needed.
"""
def override_with_linked_email(result, linked_email) do
with {:ok, record} <- extract_record(result),
true <- record.email != to_string(linked_email),
{:ok, updated_record} <- update_email_via_ecto(record, linked_email) do
# Email was different - return result with updated record
update_result_record(result, updated_record)
else
false ->
# Emails already match - no update needed
result
{:error, error} ->
# Override failed - log and propagate error
Logger.error("Email override failed: #{inspect(error)}")
{:error, error}
end
end
end

View file

@ -0,0 +1,40 @@
defmodule Mv.EmailSync.Loader do
@moduledoc """
Helper functions for loading linked records in email synchronization.
Centralizes the logic for retrieving related User/Member entities.
"""
@doc """
Loads the member linked to a user, returns nil if not linked or on error.
"""
def get_linked_member(%{member_id: nil}), do: nil
def get_linked_member(%{member_id: id}) do
case Ash.get(Mv.Membership.Member, id) do
{:ok, member} -> member
{:error, _} -> nil
end
end
@doc """
Loads the user linked to a member, returns nil if not linked or on error.
"""
def get_linked_user(member) do
case Ash.load(member, :user) do
{:ok, %{user: user}} -> user
{:error, _} -> nil
end
end
@doc """
Loads the user linked to a member, returning an error tuple if not linked.
Useful when a link is required for the operation.
"""
def load_linked_user!(member) do
case Ash.load(member, :user) do
{:ok, %{user: user}} when not is_nil(user) -> {:ok, user}
{:ok, _} -> {:error, :no_linked_user}
{:error, _} = error -> error
end
end
end

View file

@ -0,0 +1,58 @@
defmodule Mv.Membership.Member.Validations.EmailNotUsedByOtherUser do
@moduledoc """
Validates that the member's email is not already used by another user.
Only validates when:
- Member is already linked to a user (user != nil) AND email is changing
- Member is being linked to a user (user relationship is changing)
This allows creating members with the same email as unlinked users.
"""
use Ash.Resource.Validation
@impl true
def validate(changeset, _opts, _context) do
email_changing? = Ash.Changeset.changing_attribute?(changeset, :email)
linked_user_id = get_linked_user_id(changeset.data)
is_linked? = not is_nil(linked_user_id)
# Only validate if member is already linked AND email is changing
# Do NOT validate when member is being linked (email will be overridden from user)
should_validate? = is_linked? and email_changing?
if should_validate? do
new_email = Ash.Changeset.get_attribute(changeset, :email)
check_email_uniqueness(new_email, linked_user_id)
else
:ok
end
end
defp check_email_uniqueness(email, exclude_user_id) do
query =
Mv.Accounts.User
|> Ash.Query.filter(email == ^email)
|> maybe_exclude_id(exclude_user_id)
case Ash.read(query) do
{:ok, []} ->
:ok
{:ok, _} ->
{:error, field: :email, message: "is already used by another user", value: email}
{:error, _} ->
:ok
end
end
defp maybe_exclude_id(query, nil), do: query
defp maybe_exclude_id(query, id), do: Ash.Query.filter(query, id != ^id)
defp get_linked_user_id(member_data) do
case Ash.load(member_data, :user) do
{:ok, %{user: %{id: id}}} -> id
_ -> nil
end
end
end

View file

@ -8,10 +8,10 @@ defmodule MvWeb.Components.SearchBarComponent do
use MvWeb, :live_component use MvWeb, :live_component
@impl true @impl true
def update(_assigns, socket) do def update(%{query: query}, socket) do
socket = socket =
socket socket
|> assign_new(:query, fn -> "" end) |> assign_new(:query, fn -> query || "" end)
|> assign_new(:placeholder, fn -> gettext("Search...") end) |> assign_new(:placeholder, fn -> gettext("Search...") end)
{:ok, socket} {:ok, socket}
@ -20,7 +20,7 @@ defmodule MvWeb.Components.SearchBarComponent do
@impl true @impl true
def render(assigns) do def render(assigns) do
~H""" ~H"""
<form phx-change="search" phx-target={@myself} class="flex" role="search" aria-label="Search"> <form phx-submit="search" phx-target={@myself} class="flex" role="search" aria-label="Search">
<label class="input"> <label class="input">
<svg <svg
class="h-[1em] opacity-50" class="h-[1em] opacity-50"
@ -44,6 +44,9 @@ defmodule MvWeb.Components.SearchBarComponent do
placeholder={@placeholder} placeholder={@placeholder}
value={@query} value={@query}
name="query" name="query"
data-testid="search-input"
phx-change="search"
phx-target={@myself}
phx-debounce="300" phx-debounce="300"
/> />
</label> </label>

View file

@ -0,0 +1,64 @@
defmodule MvWeb.Components.SortHeaderComponent do
@moduledoc """
Sort Header that can be used as column header and sorts a table:
Props:
- field: atom() # Ash Field for sorting
- label: string() # Column Heading (can be an heex template)
- sort_field: atom() | nil # current sort field from parent liveview
- sort_order: :asc | :desc | nil # current sorting order
"""
use MvWeb, :live_component
@impl true
def update(assigns, socket) do
{:ok, assign(socket, assigns)}
end
# Check if we can add the aria-sort label directly to the daisyUI header
# aria-sort={aria_sort(@field, @sort_field, @sort_order)}
@impl true
def render(assigns) do
~H"""
<div class="tooltip" data-tip={aria_sort(@field, @sort_field, @sort_order)}>
<button
type="button"
aria-label={aria_sort(@field, @sort_field, @sort_order)}
class="btn btn-ghost select-none"
phx-click="sort"
phx-value-field={@field}
phx-target={@myself}
data-testid={@field}
>
{@label}
<%= if @sort_field == @field do %>
<.icon name={if @sort_order == :asc, do: "hero-chevron-up", else: "hero-chevron-down"} />
<% else %>
<.icon
name="hero-chevron-up-down"
class="opacity-40"
/>
<% end %>
</button>
</div>
"""
end
@impl true
def handle_event("sort", %{"field" => field_str}, socket) do
send(self(), {:sort, field_str})
{:noreply, socket}
end
# -------------------------------------------------
# Hilfsfunktionen für ARIA Attribute & Icon SVG
# -------------------------------------------------
defp aria_sort(field, sort_field, dir) when field == sort_field do
case dir do
:asc -> gettext("ascending")
:desc -> gettext("descending")
nil -> gettext("Click to sort")
end
end
defp aria_sort(_, _, _), do: gettext("Click to sort")
end

View file

@ -2,49 +2,26 @@ defmodule MvWeb.MemberLive.Index do
use MvWeb, :live_view use MvWeb, :live_view
import Ash.Expr import Ash.Expr
import Ash.Query import Ash.Query
import MvWeb.TableComponents
@impl true @impl true
def mount(_params, _session, socket) do def mount(_params, _session, socket) do
members = Ash.read!(Mv.Membership.Member) socket =
sorted = Enum.sort_by(members, & &1.first_name)
{:ok,
socket socket
|> assign(:page_title, gettext("Members")) |> assign(:page_title, gettext("Members"))
|> assign(:query, "") |> assign(:query, "")
|> assign(:sort_field, :first_name) |> assign_new(:sort_field, fn -> :first_name end)
|> assign(:sort_order, :asc) |> assign_new(:sort_order, fn -> :asc end)
|> assign(:members, sorted) |> assign(:selected_members, [])
|> assign(:selected_members, [])}
end
# ----------------------------------------------------------------- # We call handle params to use the query from the URL
# Receive messages from any toolbar component {:ok, socket}
# -----------------------------------------------------------------
# Function to handle search
@impl true
def handle_info({:search_changed, q}, socket) do
members =
if String.trim(q) == "" do
Ash.read!(Mv.Membership.Member)
else
Mv.Membership.Member
|> filter(expr(fragment("search_vector @@ plainto_tsquery('simple', ?)", ^q)))
|> Ash.read!()
end
{:noreply,
socket
|> assign(:query, q)
|> assign(:members, members)}
end end
# ----------------------------------------------------------------- # -----------------------------------------------------------------
# Handle Events # Handle Events
# ----------------------------------------------------------------- # -----------------------------------------------------------------
# Delete a member
@impl true @impl true
def handle_event("delete", %{"id" => id}, socket) do def handle_event("delete", %{"id" => id}, socket) do
member = Ash.get!(Mv.Membership.Member, id) member = Ash.get!(Mv.Membership.Member, id)
@ -67,32 +44,7 @@ defmodule MvWeb.MemberLive.Index do
{:noreply, assign(socket, :selected_members, selected)} {:noreply, assign(socket, :selected_members, selected)}
end end
# Sorts the list of members according to a field, when you click on the column header
@impl true
def handle_event("sort", %{"field" => field_str}, socket) do
members = socket.assigns.members
field = String.to_existing_atom(field_str)
new_order =
if socket.assigns.sort_field == field do
toggle_order(socket.assigns.sort_order)
else
:asc
end
sorted_members =
members
|> Enum.sort_by(&Map.get(&1, field), sort_fun(new_order))
{:noreply,
socket
|> assign(:sort_field, field)
|> assign(:sort_order, new_order)
|> assign(:members, sorted_members)}
end
# Selects all members in the list of members # Selects all members in the list of members
@impl true @impl true
def handle_event("select_all", _params, socket) do def handle_event("select_all", _params, socket) do
members = socket.assigns.members members = socket.assigns.members
@ -109,8 +61,235 @@ defmodule MvWeb.MemberLive.Index do
{:noreply, assign(socket, :selected_members, selected)} {:noreply, assign(socket, :selected_members, selected)}
end end
# -----------------------------------------------------------------
# Handle Infos from Child Components
# -----------------------------------------------------------------
# Sorts the list of members according to a field, when you click on the column header
@impl true
def handle_info({:sort, field_str}, socket) do
field = String.to_existing_atom(field_str)
old_field = socket.assigns.sort_field
{new_order, new_field} =
if socket.assigns.sort_field == field do
{toggle_order(socket.assigns.sort_order), field}
else
{:asc, field}
end
active_id = :"sort_#{new_field}"
old_id = :"sort_#{old_field}"
# Update the new SortHeader
send_update(MvWeb.Components.SortHeaderComponent,
id: active_id,
sort_field: new_field,
sort_order: new_order
)
# Reset the current SortHeader
send_update(MvWeb.Components.SortHeaderComponent,
id: old_id,
sort_field: new_field,
sort_order: new_order
)
existing_search_query = socket.assigns.query
# Build the URL with queries
query_params = %{
"query" => existing_search_query,
"sort_field" => Atom.to_string(new_field),
"sort_order" => Atom.to_string(new_order)
}
# Set the new path with params
new_path = ~p"/members?#{query_params}"
# Push the new URL
{:noreply,
push_patch(socket,
to: new_path,
replace: true
)}
end
# Function to handle search
@impl true
def handle_info({:search_changed, q}, socket) do
socket = load_members(socket, q)
existing_field_query = socket.assigns.sort_field
existing_sort_query = socket.assigns.sort_order
# Build the URL with queries
query_params = %{
"query" => q,
"sort_field" => existing_field_query,
"sort_order" => existing_sort_query
}
# Set the new path with params
new_path = ~p"/members?#{query_params}"
# Push the new URL
{:noreply,
push_patch(socket,
to: new_path,
replace: true
)}
end
# -----------------------------------------------------------------
# Handle Params from the URL
# -----------------------------------------------------------------
@impl true
def handle_params(params, _url, socket) do
socket =
socket
|> maybe_update_search(params)
|> maybe_update_sort(params)
|> load_members(params["query"])
{:noreply, socket}
end
# -------------------------------------------------------------
# FUNCTIONS
# -------------------------------------------------------------
# Load members eg based on a query for sorting
defp load_members(socket, search_query) do
query =
Mv.Membership.Member
|> Ash.Query.new()
|> Ash.Query.select([
:id,
:first_name,
:last_name,
:email,
:street,
:house_number,
:postal_code,
:city,
:phone_number,
:join_date
])
# 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)
members = Ash.read!(query)
assign(socket, :members, members)
end
# -------------------------------------------------------------
# Helper Functions
# -------------------------------------------------------------
# Function to apply search query
defp apply_search_filter(query, search_query) do
if search_query && String.trim(search_query) != "" do
query
|> filter(expr(fragment("search_vector @@ plainto_tsquery('simple', ?)", ^search_query)))
else
query
end
end
# Functions to toggle sorting order
defp toggle_order(:asc), do: :desc defp toggle_order(:asc), do: :desc
defp toggle_order(:desc), do: :asc defp toggle_order(:desc), do: :asc
defp sort_fun(:asc), do: &<=/2 defp toggle_order(nil), do: :asc
defp sort_fun(:desc), do: &>=/2
# Function to sort the column if needed
defp maybe_sort(query, nil, _), do: query
defp maybe_sort(query, field, :asc) when not is_nil(field),
do: Ash.Query.sort(query, [{field, :asc}])
defp maybe_sort(query, field, :desc) when not is_nil(field),
do: Ash.Query.sort(query, [{field, :desc}])
defp maybe_sort(query, _, _), do: query
# Validate that a field is sortable
defp valid_sort_field?(field) when is_atom(field) do
valid_fields = [
:first_name,
:last_name,
:email,
:street,
:house_number,
:postal_code,
:city,
:phone_number,
:join_date
]
field in valid_fields
end
defp valid_sort_field?(_), do: false
# Function to maybe update the sort
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)
socket
|> assign(:sort_field, field)
|> assign(:sort_order, order)
end
defp maybe_update_sort(socket, _), do: socket
defp determine_field(default, sf) do
case sf 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
end
end
defp handle_atom_conversion(val, default) when is_atom(val) do
if valid_sort_field?(val), do: val, else: default
end
defp handle_atom_conversion(_, default), do: default
defp determine_order(default, so) do
case so do
"" -> default
nil -> default
so when so in ["asc", "desc"] -> String.to_atom(so)
_ -> default
end
end
# Function to update search parameters
defp maybe_update_search(socket, %{"query" => query}) when query != "" do
assign(socket, :query, query)
end
defp maybe_update_search(socket, _params) do
# Keep the previous search query if no new one is provided
socket
end
end end

View file

@ -52,23 +52,139 @@
<:col <:col
:let={member} :let={member}
label={ label={
sort_button(%{ ~H"""
field: :first_name, <.live_component
label: gettext("Name"), module={MvWeb.Components.SortHeaderComponent}
sort_field: @sort_field, id={:sort_first_name}
sort_order: @sort_order field={:first_name}
}) label={gettext("First name")}
sort_field={@sort_field}
sort_order={@sort_order}
/>
"""
} }
> >
{member.first_name} {member.last_name} {member.first_name} {member.last_name}
</:col> </:col>
<:col :let={member} label={gettext("Email")}>{member.email}</:col> <:col
<:col :let={member} label={gettext("Street")}>{member.street}</:col> :let={member}
<:col :let={member} label={gettext("House Number")}>{member.house_number}</:col> label={
<:col :let={member} label={gettext("Postal Code")}>{member.postal_code}</:col> ~H"""
<:col :let={member} label={gettext("City")}>{member.city}</:col> <.live_component
<:col :let={member} label={gettext("Phone Number")}>{member.phone_number}</:col> module={MvWeb.Components.SortHeaderComponent}
<:col :let={member} label={gettext("Join Date")}>{member.join_date}</:col> id={:sort_email}
field={:email}
label={gettext("Email")}
sort_field={@sort_field}
sort_order={@sort_order}
/>
"""
}
>
{member.email}
</:col>
<:col
:let={member}
label={
~H"""
<.live_component
module={MvWeb.Components.SortHeaderComponent}
id={:sort_street}
field={:street}
label={gettext("Street")}
sort_field={@sort_field}
sort_order={@sort_order}
/>
"""
}
>
{member.street}
</:col>
<:col
:let={member}
label={
~H"""
<.live_component
module={MvWeb.Components.SortHeaderComponent}
id={:sort_house_number}
field={:house_number}
label={gettext("House Number")}
sort_field={@sort_field}
sort_order={@sort_order}
/>
"""
}
>
{member.house_number}
</:col>
<:col
:let={member}
label={
~H"""
<.live_component
module={MvWeb.Components.SortHeaderComponent}
id={:sort_postal_code}
field={:postal_code}
label={gettext("Postal Code")}
sort_field={@sort_field}
sort_order={@sort_order}
/>
"""
}
>
{member.postal_code}
</:col>
<:col
:let={member}
label={
~H"""
<.live_component
module={MvWeb.Components.SortHeaderComponent}
id={:sort_city}
field={:city}
label={gettext("City")}
sort_field={@sort_field}
sort_order={@sort_order}
/>
"""
}
>
{member.city}
</:col>
<:col
:let={member}
label={
~H"""
<.live_component
module={MvWeb.Components.SortHeaderComponent}
id={:sort_phone_number}
field={:phone_number}
label={gettext("Phone Number")}
sort_field={@sort_field}
sort_order={@sort_order}
/>
"""
}
>
{member.phone_number}
</:col>
<:col
:let={member}
label={
~H"""
<.live_component
module={MvWeb.Components.SortHeaderComponent}
id={:sort_join_date}
field={:join_date}
label={gettext("Join Date")}
sort_field={@sort_field}
sort_order={@sort_order}
/>
"""
}
>
{member.join_date}
</:col>
<:action :let={member}> <:action :let={member}>
<div class="sr-only"> <div class="sr-only">

View file

@ -61,3 +61,6 @@ msgstr "Anmelden..."
msgid "Your password has successfully been reset" msgid "Your password has successfully been reset"
msgstr "Das Passwort wurde erfolgreich zurückgesetzt" msgstr "Das Passwort wurde erfolgreich zurückgesetzt"
#~ msgid "Sign in with Rauthy"
#~ msgstr "Anmelden mit der Vereinscloud"

View file

@ -15,7 +15,7 @@ msgstr ""
msgid "Actions" msgid "Actions"
msgstr "Aktionen" msgstr "Aktionen"
#: lib/mv_web/live/member_live/index.html.heex:84 #: lib/mv_web/live/member_live/index.html.heex:193
#: lib/mv_web/live/user_live/index.html.heex:65 #: lib/mv_web/live/user_live/index.html.heex:65
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Are you sure?" msgid "Are you sure?"
@ -28,19 +28,19 @@ msgid "Attempting to reconnect"
msgstr "Verbindung wird wiederhergestellt" msgstr "Verbindung wird wiederhergestellt"
#: lib/mv_web/live/member_live/form.ex:25 #: lib/mv_web/live/member_live/form.ex:25
#: lib/mv_web/live/member_live/index.html.heex:69 #: lib/mv_web/live/member_live/index.html.heex:138
#: lib/mv_web/live/member_live/show.ex:37 #: lib/mv_web/live/member_live/show.ex:36
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "City" msgid "City"
msgstr "Stadt" msgstr "Stadt"
#: lib/mv_web/live/member_live/index.html.heex:86 #: lib/mv_web/live/member_live/index.html.heex:195
#: lib/mv_web/live/user_live/index.html.heex:67 #: lib/mv_web/live/user_live/index.html.heex:67
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Delete" msgid "Delete"
msgstr "Löschen" msgstr "Löschen"
#: lib/mv_web/live/member_live/index.html.heex:78 #: lib/mv_web/live/member_live/index.html.heex:187
#: lib/mv_web/live/user_live/form.ex:109 #: lib/mv_web/live/user_live/form.ex:109
#: lib/mv_web/live/user_live/index.html.heex:59 #: lib/mv_web/live/user_live/index.html.heex:59
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
@ -54,8 +54,8 @@ msgid "Edit Member"
msgstr "Mitglied bearbeiten" msgstr "Mitglied bearbeiten"
#: lib/mv_web/live/member_live/form.ex:18 #: lib/mv_web/live/member_live/form.ex:18
#: lib/mv_web/live/member_live/index.html.heex:65 #: lib/mv_web/live/member_live/index.html.heex:70
#: lib/mv_web/live/member_live/show.ex:28 #: lib/mv_web/live/member_live/show.ex:27
#: lib/mv_web/live/user_live/form.ex:14 #: lib/mv_web/live/user_live/form.ex:14
#: lib/mv_web/live/user_live/index.html.heex:44 #: lib/mv_web/live/user_live/index.html.heex:44
#: lib/mv_web/live/user_live/show.ex:25 #: lib/mv_web/live/user_live/show.ex:25
@ -70,8 +70,8 @@ msgid "First Name"
msgstr "Vorname" msgstr "Vorname"
#: lib/mv_web/live/member_live/form.ex:22 #: lib/mv_web/live/member_live/form.ex:22
#: lib/mv_web/live/member_live/index.html.heex:71 #: lib/mv_web/live/member_live/index.html.heex:172
#: lib/mv_web/live/member_live/show.ex:34 #: lib/mv_web/live/member_live/show.ex:33
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Join Date" msgid "Join Date"
msgstr "Beitrittsdatum" msgstr "Beitrittsdatum"
@ -87,7 +87,7 @@ msgstr "Nachname"
msgid "New Member" msgid "New Member"
msgstr "Neues Mitglied" msgstr "Neues Mitglied"
#: lib/mv_web/live/member_live/index.html.heex:75 #: lib/mv_web/live/member_live/index.html.heex:184
#: lib/mv_web/live/user_live/index.html.heex:56 #: lib/mv_web/live/user_live/index.html.heex:56
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Show" msgid "Show"
@ -127,8 +127,8 @@ msgid "Exit Date"
msgstr "Austrittsdatum" msgstr "Austrittsdatum"
#: lib/mv_web/live/member_live/form.ex:27 #: lib/mv_web/live/member_live/form.ex:27
#: lib/mv_web/live/member_live/index.html.heex:67 #: lib/mv_web/live/member_live/index.html.heex:104
#: lib/mv_web/live/member_live/show.ex:39 #: lib/mv_web/live/member_live/show.ex:38
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "House Number" msgid "House Number"
msgstr "Hausnummer" msgstr "Hausnummer"
@ -146,15 +146,15 @@ msgid "Paid"
msgstr "Bezahlt" msgstr "Bezahlt"
#: lib/mv_web/live/member_live/form.ex:21 #: lib/mv_web/live/member_live/form.ex:21
#: lib/mv_web/live/member_live/index.html.heex:70 #: lib/mv_web/live/member_live/index.html.heex:155
#: lib/mv_web/live/member_live/show.ex:33 #: lib/mv_web/live/member_live/show.ex:32
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Phone Number" msgid "Phone Number"
msgstr "Telefonnummer" msgstr "Telefonnummer"
#: lib/mv_web/live/member_live/form.ex:28 #: lib/mv_web/live/member_live/form.ex:28
#: lib/mv_web/live/member_live/index.html.heex:68 #: lib/mv_web/live/member_live/index.html.heex:121
#: lib/mv_web/live/member_live/show.ex:40 #: lib/mv_web/live/member_live/show.ex:39
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Postal Code" msgid "Postal Code"
msgstr "Postleitzahl" msgstr "Postleitzahl"
@ -173,8 +173,8 @@ msgid "Saving..."
msgstr "Speichern..." msgstr "Speichern..."
#: lib/mv_web/live/member_live/form.ex:26 #: lib/mv_web/live/member_live/form.ex:26
#: lib/mv_web/live/member_live/index.html.heex:66 #: lib/mv_web/live/member_live/index.html.heex:87
#: lib/mv_web/live/member_live/show.ex:38 #: lib/mv_web/live/member_live/show.ex:37
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Street" msgid "Street"
msgstr "Straße" msgstr "Straße"
@ -317,14 +317,13 @@ msgstr "Benutzer*innen auflisten"
msgid "Member" msgid "Member"
msgstr "Mitglied" msgstr "Mitglied"
#: lib/mv_web/components/layouts/navbar.ex:19 #: lib/mv_web/components/layouts/navbar.ex:14
#: lib/mv_web/live/member_live/index.ex:14 #: lib/mv_web/live/member_live/index.ex:8
#: lib/mv_web/live/member_live/index.html.heex:3 #: lib/mv_web/live/member_live/index.html.heex:3
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Members" msgid "Members"
msgstr "Mitglieder" msgstr "Mitglieder"
#: lib/mv_web/live/member_live/index.html.heex:57
#: lib/mv_web/live/property_type_live/form.ex:16 #: lib/mv_web/live/property_type_live/form.ex:16
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Name" msgid "Name"
@ -469,11 +468,13 @@ msgid "Value type"
msgstr "Wertetyp" msgstr "Wertetyp"
#: lib/mv_web/components/table_components.ex:30 #: lib/mv_web/components/table_components.ex:30
#: lib/mv_web/live/components/sort_header_component.ex:55
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "ascending" msgid "ascending"
msgstr "aufsteigend" msgstr "aufsteigend"
#: lib/mv_web/components/table_components.ex:30 #: lib/mv_web/components/table_components.ex:30
#: lib/mv_web/live/components/sort_header_component.ex:56
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "descending" msgid "descending"
msgstr "absteigend" msgstr "absteigend"
@ -600,10 +601,15 @@ msgstr "Dunklen Modus umschalten"
#: lib/mv_web/live/components/search_bar_component.ex:15 #: lib/mv_web/live/components/search_bar_component.ex:15
#: lib/mv_web/live/member_live/index.html.heex:15 #: lib/mv_web/live/member_live/index.html.heex:15
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Search..." msgid "Click to sort"
msgstr "" msgstr "Klicke um zu sortieren"
#: lib/mv_web/components/layouts/navbar.ex:20 #: lib/mv_web/live/member_live/index.html.heex:53
#, elixir-autogen, elixir-format, fuzzy #, elixir-autogen, elixir-format, fuzzy
msgid "Users" msgid "First name"
msgstr "Benutzer*innen" msgstr "Vorname"
#~ #: lib/mv_web/auth_overrides.ex:30
#~ #, elixir-autogen, elixir-format
#~ msgid "or"
#~ msgstr "oder"

View file

@ -16,7 +16,7 @@ msgstr ""
msgid "Actions" msgid "Actions"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/index.html.heex:84 #: lib/mv_web/live/member_live/index.html.heex:193
#: lib/mv_web/live/user_live/index.html.heex:65 #: lib/mv_web/live/user_live/index.html.heex:65
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Are you sure?" msgid "Are you sure?"
@ -29,19 +29,19 @@ msgid "Attempting to reconnect"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/form.ex:25 #: lib/mv_web/live/member_live/form.ex:25
#: lib/mv_web/live/member_live/index.html.heex:69 #: lib/mv_web/live/member_live/index.html.heex:138
#: lib/mv_web/live/member_live/show.ex:37 #: lib/mv_web/live/member_live/show.ex:36
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "City" msgid "City"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/index.html.heex:86 #: lib/mv_web/live/member_live/index.html.heex:195
#: lib/mv_web/live/user_live/index.html.heex:67 #: lib/mv_web/live/user_live/index.html.heex:67
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Delete" msgid "Delete"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/index.html.heex:78 #: lib/mv_web/live/member_live/index.html.heex:187
#: lib/mv_web/live/user_live/form.ex:109 #: lib/mv_web/live/user_live/form.ex:109
#: lib/mv_web/live/user_live/index.html.heex:59 #: lib/mv_web/live/user_live/index.html.heex:59
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
@ -55,8 +55,8 @@ msgid "Edit Member"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/form.ex:18 #: lib/mv_web/live/member_live/form.ex:18
#: lib/mv_web/live/member_live/index.html.heex:65 #: lib/mv_web/live/member_live/index.html.heex:70
#: lib/mv_web/live/member_live/show.ex:28 #: lib/mv_web/live/member_live/show.ex:27
#: lib/mv_web/live/user_live/form.ex:14 #: lib/mv_web/live/user_live/form.ex:14
#: lib/mv_web/live/user_live/index.html.heex:44 #: lib/mv_web/live/user_live/index.html.heex:44
#: lib/mv_web/live/user_live/show.ex:25 #: lib/mv_web/live/user_live/show.ex:25
@ -71,8 +71,8 @@ msgid "First Name"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/form.ex:22 #: lib/mv_web/live/member_live/form.ex:22
#: lib/mv_web/live/member_live/index.html.heex:71 #: lib/mv_web/live/member_live/index.html.heex:172
#: lib/mv_web/live/member_live/show.ex:34 #: lib/mv_web/live/member_live/show.ex:33
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Join Date" msgid "Join Date"
msgstr "" msgstr ""
@ -88,7 +88,7 @@ msgstr ""
msgid "New Member" msgid "New Member"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/index.html.heex:75 #: lib/mv_web/live/member_live/index.html.heex:184
#: lib/mv_web/live/user_live/index.html.heex:56 #: lib/mv_web/live/user_live/index.html.heex:56
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Show" msgid "Show"
@ -128,8 +128,8 @@ msgid "Exit Date"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/form.ex:27 #: lib/mv_web/live/member_live/form.ex:27
#: lib/mv_web/live/member_live/index.html.heex:67 #: lib/mv_web/live/member_live/index.html.heex:104
#: lib/mv_web/live/member_live/show.ex:39 #: lib/mv_web/live/member_live/show.ex:38
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "House Number" msgid "House Number"
msgstr "" msgstr ""
@ -147,15 +147,15 @@ msgid "Paid"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/form.ex:21 #: lib/mv_web/live/member_live/form.ex:21
#: lib/mv_web/live/member_live/index.html.heex:70 #: lib/mv_web/live/member_live/index.html.heex:155
#: lib/mv_web/live/member_live/show.ex:33 #: lib/mv_web/live/member_live/show.ex:32
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Phone Number" msgid "Phone Number"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/form.ex:28 #: lib/mv_web/live/member_live/form.ex:28
#: lib/mv_web/live/member_live/index.html.heex:68 #: lib/mv_web/live/member_live/index.html.heex:121
#: lib/mv_web/live/member_live/show.ex:40 #: lib/mv_web/live/member_live/show.ex:39
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Postal Code" msgid "Postal Code"
msgstr "" msgstr ""
@ -174,8 +174,8 @@ msgid "Saving..."
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/form.ex:26 #: lib/mv_web/live/member_live/form.ex:26
#: lib/mv_web/live/member_live/index.html.heex:66 #: lib/mv_web/live/member_live/index.html.heex:87
#: lib/mv_web/live/member_live/show.ex:38 #: lib/mv_web/live/member_live/show.ex:37
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Street" msgid "Street"
msgstr "" msgstr ""
@ -318,14 +318,13 @@ msgstr ""
msgid "Member" msgid "Member"
msgstr "" msgstr ""
#: lib/mv_web/components/layouts/navbar.ex:19 #: lib/mv_web/components/layouts/navbar.ex:14
#: lib/mv_web/live/member_live/index.ex:14 #: lib/mv_web/live/member_live/index.ex:8
#: lib/mv_web/live/member_live/index.html.heex:3 #: lib/mv_web/live/member_live/index.html.heex:3
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Members" msgid "Members"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/index.html.heex:57
#: lib/mv_web/live/property_type_live/form.ex:16 #: lib/mv_web/live/property_type_live/form.ex:16
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Name" msgid "Name"
@ -470,11 +469,13 @@ msgid "Value type"
msgstr "" msgstr ""
#: lib/mv_web/components/table_components.ex:30 #: lib/mv_web/components/table_components.ex:30
#: lib/mv_web/live/components/sort_header_component.ex:55
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "ascending" msgid "ascending"
msgstr "" msgstr ""
#: lib/mv_web/components/table_components.ex:30 #: lib/mv_web/components/table_components.ex:30
#: lib/mv_web/live/components/sort_header_component.ex:56
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "descending" msgid "descending"
msgstr "" msgstr ""
@ -607,4 +608,12 @@ msgstr ""
#: lib/mv_web/components/layouts/navbar.ex:20 #: lib/mv_web/components/layouts/navbar.ex:20
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Users" msgid "Users"
#: lib/mv_web/live/components/sort_header_component.ex:60
#, elixir-autogen, elixir-format
msgid "Click to sort"
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex:53
#, elixir-autogen, elixir-format
msgid "First name"
msgstr "" msgstr ""

View file

@ -58,3 +58,6 @@ msgstr ""
msgid "Your password has successfully been reset" msgid "Your password has successfully been reset"
msgstr "" msgstr ""
#~ msgid "Sign in with Rauthy"
#~ msgstr "Sign in with Vereinscloud"

View file

@ -16,7 +16,7 @@ msgstr ""
msgid "Actions" msgid "Actions"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/index.html.heex:84 #: lib/mv_web/live/member_live/index.html.heex:193
#: lib/mv_web/live/user_live/index.html.heex:65 #: lib/mv_web/live/user_live/index.html.heex:65
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Are you sure?" msgid "Are you sure?"
@ -29,19 +29,19 @@ msgid "Attempting to reconnect"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/form.ex:25 #: lib/mv_web/live/member_live/form.ex:25
#: lib/mv_web/live/member_live/index.html.heex:69 #: lib/mv_web/live/member_live/index.html.heex:138
#: lib/mv_web/live/member_live/show.ex:37 #: lib/mv_web/live/member_live/show.ex:36
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "City" msgid "City"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/index.html.heex:86 #: lib/mv_web/live/member_live/index.html.heex:195
#: lib/mv_web/live/user_live/index.html.heex:67 #: lib/mv_web/live/user_live/index.html.heex:67
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Delete" msgid "Delete"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/index.html.heex:78 #: lib/mv_web/live/member_live/index.html.heex:187
#: lib/mv_web/live/user_live/form.ex:109 #: lib/mv_web/live/user_live/form.ex:109
#: lib/mv_web/live/user_live/index.html.heex:59 #: lib/mv_web/live/user_live/index.html.heex:59
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
@ -55,8 +55,8 @@ msgid "Edit Member"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/form.ex:18 #: lib/mv_web/live/member_live/form.ex:18
#: lib/mv_web/live/member_live/index.html.heex:65 #: lib/mv_web/live/member_live/index.html.heex:70
#: lib/mv_web/live/member_live/show.ex:28 #: lib/mv_web/live/member_live/show.ex:27
#: lib/mv_web/live/user_live/form.ex:14 #: lib/mv_web/live/user_live/form.ex:14
#: lib/mv_web/live/user_live/index.html.heex:44 #: lib/mv_web/live/user_live/index.html.heex:44
#: lib/mv_web/live/user_live/show.ex:25 #: lib/mv_web/live/user_live/show.ex:25
@ -71,8 +71,8 @@ msgid "First Name"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/form.ex:22 #: lib/mv_web/live/member_live/form.ex:22
#: lib/mv_web/live/member_live/index.html.heex:71 #: lib/mv_web/live/member_live/index.html.heex:172
#: lib/mv_web/live/member_live/show.ex:34 #: lib/mv_web/live/member_live/show.ex:33
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Join Date" msgid "Join Date"
msgstr "" msgstr ""
@ -88,7 +88,7 @@ msgstr ""
msgid "New Member" msgid "New Member"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/index.html.heex:75 #: lib/mv_web/live/member_live/index.html.heex:184
#: lib/mv_web/live/user_live/index.html.heex:56 #: lib/mv_web/live/user_live/index.html.heex:56
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Show" msgid "Show"
@ -128,8 +128,8 @@ msgid "Exit Date"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/form.ex:27 #: lib/mv_web/live/member_live/form.ex:27
#: lib/mv_web/live/member_live/index.html.heex:67 #: lib/mv_web/live/member_live/index.html.heex:104
#: lib/mv_web/live/member_live/show.ex:39 #: lib/mv_web/live/member_live/show.ex:38
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "House Number" msgid "House Number"
msgstr "" msgstr ""
@ -147,15 +147,15 @@ msgid "Paid"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/form.ex:21 #: lib/mv_web/live/member_live/form.ex:21
#: lib/mv_web/live/member_live/index.html.heex:70 #: lib/mv_web/live/member_live/index.html.heex:155
#: lib/mv_web/live/member_live/show.ex:33 #: lib/mv_web/live/member_live/show.ex:32
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Phone Number" msgid "Phone Number"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/form.ex:28 #: lib/mv_web/live/member_live/form.ex:28
#: lib/mv_web/live/member_live/index.html.heex:68 #: lib/mv_web/live/member_live/index.html.heex:121
#: lib/mv_web/live/member_live/show.ex:40 #: lib/mv_web/live/member_live/show.ex:39
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Postal Code" msgid "Postal Code"
msgstr "" msgstr ""
@ -174,8 +174,8 @@ msgid "Saving..."
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/form.ex:26 #: lib/mv_web/live/member_live/form.ex:26
#: lib/mv_web/live/member_live/index.html.heex:66 #: lib/mv_web/live/member_live/index.html.heex:87
#: lib/mv_web/live/member_live/show.ex:38 #: lib/mv_web/live/member_live/show.ex:37
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Street" msgid "Street"
msgstr "" msgstr ""
@ -318,14 +318,13 @@ msgstr ""
msgid "Member" msgid "Member"
msgstr "" msgstr ""
#: lib/mv_web/components/layouts/navbar.ex:19 #: lib/mv_web/components/layouts/navbar.ex:14
#: lib/mv_web/live/member_live/index.ex:14 #: lib/mv_web/live/member_live/index.ex:8
#: lib/mv_web/live/member_live/index.html.heex:3 #: lib/mv_web/live/member_live/index.html.heex:3
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Members" msgid "Members"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/index.html.heex:57
#: lib/mv_web/live/property_type_live/form.ex:16 #: lib/mv_web/live/property_type_live/form.ex:16
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Name" msgid "Name"
@ -470,11 +469,13 @@ msgid "Value type"
msgstr "" msgstr ""
#: lib/mv_web/components/table_components.ex:30 #: lib/mv_web/components/table_components.ex:30
#: lib/mv_web/live/components/sort_header_component.ex:55
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "ascending" msgid "ascending"
msgstr "" msgstr ""
#: lib/mv_web/components/table_components.ex:30 #: lib/mv_web/components/table_components.ex:30
#: lib/mv_web/live/components/sort_header_component.ex:56
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "descending" msgid "descending"
msgstr "" msgstr ""
@ -554,57 +555,17 @@ msgstr "Set Password"
msgid "User will be created without a password. Check 'Set Password' to add one." 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." msgstr "User will be created without a password. Check 'Set Password' to add one."
#: lib/mv_web/live/user_live/show.ex:30 #: lib/mv_web/live/components/sort_header_component.ex:60
#, elixir-autogen, elixir-format
msgid "Click to sort"
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex:53
#, elixir-autogen, elixir-format, fuzzy #, elixir-autogen, elixir-format, fuzzy
msgid "Linked Member" msgid "First name"
msgstr "" msgstr ""
#: lib/mv_web/live/member_live/show.ex:41 #~ #: lib/mv_web/auth_overrides.ex:30
#, elixir-autogen, elixir-format #~ #, elixir-autogen, elixir-format
msgid "Linked User" #~ msgid "or"
msgstr "" #~ msgstr ""
#: lib/mv_web/live/user_live/show.ex:40
#, elixir-autogen, elixir-format
msgid "No member linked"
msgstr ""
#: lib/mv_web/live/member_live/show.ex:51
#, elixir-autogen, elixir-format
msgid "No user linked"
msgstr ""
#: lib/mv_web/live/member_live/show.ex:14
#: lib/mv_web/live/member_live/show.ex:16
#, elixir-autogen, elixir-format
msgid "Back to members list"
msgstr ""
#: lib/mv_web/live/user_live/show.ex:13
#: lib/mv_web/live/user_live/show.ex:15
#, elixir-autogen, elixir-format
msgid "Back to users list"
msgstr ""
#: lib/mv_web/components/layouts/navbar.ex:27
#: lib/mv_web/components/layouts/navbar.ex:33
#, elixir-autogen, elixir-format, fuzzy
msgid "Select language"
msgstr ""
#: lib/mv_web/components/layouts/navbar.ex:40
#: lib/mv_web/components/layouts/navbar.ex:60
#, elixir-autogen, elixir-format
msgid "Toggle dark mode"
msgstr ""
#: lib/mv_web/live/components/search_bar_component.ex:15
#: lib/mv_web/live/member_live/index.html.heex:15
#, elixir-autogen, elixir-format
msgid "Search..."
msgstr ""
#: lib/mv_web/components/layouts/navbar.ex:20
#, elixir-autogen, elixir-format, fuzzy
msgid "Users"
msgstr ""

View file

@ -88,6 +88,18 @@ for member_attrs <- [
city: "Berlin", city: "Berlin",
street: "Kastanienallee", street: "Kastanienallee",
house_number: "8" house_number: "8"
},
%{
first_name: "Marianne",
last_name: "Wagner",
email: "marianne.wagner@example.de",
birth_date: ~D[1978-11-08],
join_date: ~D[2022-11-10],
paid: true,
phone_number: "+49301122334",
city: "Berlin",
street: "Kastanienallee",
house_number: "8"
} }
] do ] do
# Use upsert to prevent duplicates based on email # Use upsert to prevent duplicates based on email

View file

@ -0,0 +1,93 @@
defmodule Mv.Accounts.EmailSyncEdgeCasesTest do
@moduledoc """
Edge case tests for email synchronization between User and Member.
Tests various boundary conditions and validation scenarios.
"""
use Mv.DataCase, async: false
alias Mv.Accounts
alias Mv.Membership
describe "Email sync edge cases" do
@valid_user_attrs %{
email: "user@example.com"
}
@valid_member_attrs %{
first_name: "John",
last_name: "Doe",
email: "member@example.com"
}
test "simultaneous email updates use user email as source of truth" do
# Create linked user and member
{:ok, member} = Membership.create_member(@valid_member_attrs)
{:ok, user} =
Accounts.create_user(Map.put(@valid_user_attrs, :member, %{id: member.id}))
# Verify link and initial sync
{:ok, synced_member} = Ash.get(Mv.Membership.Member, member.id)
assert synced_member.email == "user@example.com"
# Scenario: Both emails are updated "simultaneously"
# In practice, this tests that when a member email is updated,
# it syncs to user, and user remains the source of truth
# Update member email first
{:ok, _updated_member} =
Membership.update_member(member, %{email: "member-new@example.com"})
# Verify it synced to user
{:ok, user_after_member_update} = Ash.get(Mv.Accounts.User, user.id)
assert to_string(user_after_member_update.email) == "member-new@example.com"
# Now update user email - this should override
{:ok, _updated_user} =
Accounts.update_user(user_after_member_update, %{email: "user-final@example.com"})
# Reload both
{:ok, final_user} = Ash.get(Mv.Accounts.User, user.id)
{:ok, final_member} = Ash.get(Mv.Membership.Member, member.id)
# User email should be the final truth
assert to_string(final_user.email) == "user-final@example.com"
assert final_member.email == "user-final@example.com"
end
test "email validation works for both user and member" do
# Test that invalid emails are rejected for both resources
# Invalid email for user
invalid_user_result = Accounts.create_user(%{email: "not-an-email"})
assert {:error, %Ash.Error.Invalid{}} = invalid_user_result
# Invalid email for member
invalid_member_attrs = Map.put(@valid_member_attrs, :email, "also-not-an-email")
invalid_member_result = Membership.create_member(invalid_member_attrs)
assert {:error, %Ash.Error.Invalid{}} = invalid_member_result
# Valid emails should work
{:ok, _user} = Accounts.create_user(@valid_user_attrs)
{:ok, _member} = Membership.create_member(@valid_member_attrs)
end
test "identity constraints prevent duplicate emails" do
# Create first user with an email
{:ok, user1} = Accounts.create_user(%{email: "duplicate@example.com"})
assert to_string(user1.email) == "duplicate@example.com"
# Try to create second user with same email - should fail due to unique constraint
result = Accounts.create_user(%{email: "duplicate@example.com"})
assert {:error, %Ash.Error.Invalid{}} = result
# Same for members
member_attrs = Map.put(@valid_member_attrs, :email, "member-dup@example.com")
{:ok, member1} = Membership.create_member(member_attrs)
assert member1.email == "member-dup@example.com"
# Try to create second member with same email - should fail
result2 = Membership.create_member(member_attrs)
assert {:error, %Ash.Error.Invalid{}} = result2
end
end
end

View file

@ -0,0 +1,480 @@
defmodule Mv.Accounts.EmailUniquenessTest do
use Mv.DataCase, async: false
alias Mv.Accounts
alias Mv.Membership
describe "Email uniqueness validation - Creation" do
test "CAN create member with existing unlinked user email" do
# Create a user with email
{:ok, _user} =
Accounts.create_user(%{
email: "existing@example.com"
})
# Create member with same email - should succeed
{:ok, member} =
Membership.create_member(%{
first_name: "John",
last_name: "Doe",
email: "existing@example.com"
})
assert to_string(member.email) == "existing@example.com"
end
test "CAN create user with existing unlinked member email" do
# Create a member with email
{:ok, _member} =
Membership.create_member(%{
first_name: "John",
last_name: "Doe",
email: "existing@example.com"
})
# Create user with same email - should succeed
{:ok, user} =
Accounts.create_user(%{
email: "existing@example.com"
})
assert to_string(user.email) == "existing@example.com"
end
end
describe "Email uniqueness validation - Updating unlinked entities" do
test "unlinked member email CAN be changed to an existing unlinked user email" do
# Create a user with email
{:ok, _user} =
Accounts.create_user(%{
email: "existing_user@example.com"
})
# Create an unlinked member with different email
{:ok, member} =
Membership.create_member(%{
first_name: "John",
last_name: "Doe",
email: "member@example.com"
})
# Change member email to existing user email - should succeed (member is unlinked)
{:ok, updated_member} =
Membership.update_member(member, %{
email: "existing_user@example.com"
})
assert to_string(updated_member.email) == "existing_user@example.com"
end
test "unlinked user email CAN be changed to an existing unlinked member email" do
# Create a member with email
{:ok, _member} =
Membership.create_member(%{
first_name: "John",
last_name: "Doe",
email: "existing_member@example.com"
})
# Create an unlinked user with different email
{:ok, user} =
Accounts.create_user(%{
email: "user@example.com"
})
# Change user email to existing member email - should succeed (user is unlinked)
{:ok, updated_user} =
Accounts.update_user(user, %{
email: "existing_member@example.com"
})
assert to_string(updated_user.email) == "existing_member@example.com"
end
test "unlinked member email CANNOT be changed to an existing linked user email" do
# Create a user and link it to a member - this makes the user "linked"
{:ok, user} =
Accounts.create_user(%{
email: "linked_user@example.com"
})
{:ok, _member_a} =
Membership.create_member(%{
first_name: "Member",
last_name: "A",
email: "temp@example.com",
user: %{id: user.id}
})
# Create an unlinked member with different email
{:ok, member_b} =
Membership.create_member(%{
first_name: "Member",
last_name: "B",
email: "member_b@example.com"
})
# Try to change unlinked member's email to linked user's email - should fail
result =
Membership.update_member(member_b, %{
email: "linked_user@example.com"
})
assert {:error, %Ash.Error.Invalid{} = error} = result
assert error.errors
|> Enum.any?(fn e ->
e.field == :email and
(String.contains?(e.message, "already") or String.contains?(e.message, "used"))
end)
end
test "unlinked user email CANNOT be changed to an existing linked member email" do
# Create a user and link it to a member - this makes the member "linked"
{:ok, user_a} =
Accounts.create_user(%{
email: "user_a@example.com"
})
{:ok, _member_a} =
Membership.create_member(%{
first_name: "Member",
last_name: "A",
email: "temp@example.com",
user: %{id: user_a.id}
})
# Reload user to get updated member_id and linked member email
{:ok, user_a_reloaded} = Ash.get(Mv.Accounts.User, user_a.id)
{:ok, user_a_with_member} = Ash.load(user_a_reloaded, :member)
linked_member_email = to_string(user_a_with_member.member.email)
# Create an unlinked user with different email
{:ok, user_b} =
Accounts.create_user(%{
email: "user_b@example.com"
})
# Try to change unlinked user's email to linked member's email - should fail
result =
Accounts.update_user(user_b, %{
email: linked_member_email
})
assert {:error, %Ash.Error.Invalid{} = error} = result
assert error.errors
|> Enum.any?(fn e ->
e.field == :email and
(String.contains?(e.message, "already") or String.contains?(e.message, "used"))
end)
end
end
describe "Email uniqueness validation - Creating with linked emails" do
test "CANNOT create member with existing linked user email" do
# Create a user and link it to a member
{:ok, user} =
Accounts.create_user(%{
email: "linked@example.com"
})
{:ok, _member} =
Membership.create_member(%{
first_name: "First",
last_name: "Member",
email: "temp@example.com",
user: %{id: user.id}
})
# Try to create a new member with the linked user's email - should fail
result =
Membership.create_member(%{
first_name: "Second",
last_name: "Member",
email: "linked@example.com"
})
assert {:error, %Ash.Error.Invalid{} = error} = result
assert error.errors
|> Enum.any?(fn e ->
e.field == :email and
(String.contains?(e.message, "already") or String.contains?(e.message, "used"))
end)
end
test "CANNOT create user with existing linked member email" do
# Create a user and link it to a member
{:ok, user} =
Accounts.create_user(%{
email: "user@example.com"
})
{:ok, _member} =
Membership.create_member(%{
first_name: "Member",
last_name: "One",
email: "temp@example.com",
user: %{id: user.id}
})
# Reload user to get the linked member's email
{:ok, user_reloaded} = Ash.get(Mv.Accounts.User, user.id)
{:ok, user_with_member} = Ash.load(user_reloaded, :member)
linked_member_email = to_string(user_with_member.member.email)
# Try to create a new user with the linked member's email - should fail
result =
Accounts.create_user(%{
email: linked_member_email
})
assert {:error, %Ash.Error.Invalid{} = error} = result
assert error.errors
|> Enum.any?(fn e ->
e.field == :email and
(String.contains?(e.message, "already") or String.contains?(e.message, "used"))
end)
end
end
describe "Email uniqueness validation - Updating linked entities" do
test "linked member email CANNOT be changed to an existing user email" do
# Create a user with email
{:ok, _other_user} =
Accounts.create_user(%{
email: "other_user@example.com"
})
# Create a user and link it to a member
{:ok, user} =
Accounts.create_user(%{
email: "user@example.com"
})
{:ok, member} =
Membership.create_member(%{
first_name: "John",
last_name: "Doe",
email: "temp@example.com",
user: %{id: user.id}
})
# Try to change linked member's email to other user's email - should fail
result =
Membership.update_member(member, %{
email: "other_user@example.com"
})
assert {:error, %Ash.Error.Invalid{} = error} = result
assert error.errors
|> Enum.any?(fn e ->
e.field == :email and
(String.contains?(e.message, "already") or String.contains?(e.message, "used"))
end)
end
test "linked user email CANNOT be changed to an existing member email" do
# Create a member with email
{:ok, _other_member} =
Membership.create_member(%{
first_name: "Jane",
last_name: "Doe",
email: "other_member@example.com"
})
# Create a user and link it to a member
{:ok, user} =
Accounts.create_user(%{
email: "user@example.com"
})
{:ok, _member} =
Membership.create_member(%{
first_name: "John",
last_name: "Doe",
email: "temp@example.com",
user: %{id: user.id}
})
# Reload user to get updated member_id
{:ok, user_reloaded} = Ash.get(Mv.Accounts.User, user.id)
# Try to change linked user's email to other member's email - should fail
result =
Accounts.update_user(user_reloaded, %{
email: "other_member@example.com"
})
assert {:error, %Ash.Error.Invalid{} = error} = result
assert error.errors
|> Enum.any?(fn e ->
e.field == :email and
(String.contains?(e.message, "already") or String.contains?(e.message, "used"))
end)
end
end
describe "Email uniqueness validation - Linking" do
test "CANNOT link user to member if user email is already used by another unlinked member" do
# Create a member with email
{:ok, _other_member} =
Membership.create_member(%{
first_name: "Jane",
last_name: "Doe",
email: "duplicate@example.com"
})
# Create a user with same email
{:ok, user} =
Accounts.create_user(%{
email: "duplicate@example.com"
})
# Create a member to link with the user
{:ok, member} =
Membership.create_member(%{
first_name: "John",
last_name: "Smith",
email: "john@example.com"
})
# Try to link user to member - should fail because user.email is already used by other_member
result =
Accounts.update_user(user, %{
member: %{id: member.id}
})
assert {:error, %Ash.Error.Invalid{} = error} = result
assert error.errors
|> Enum.any?(fn e ->
e.field == :email and
(String.contains?(e.message, "already") or String.contains?(e.message, "used"))
end)
end
test "CAN link member to user even if member email is used by another user (member email gets overridden)" do
# Create a user with email
{:ok, _other_user} =
Accounts.create_user(%{
email: "duplicate@example.com"
})
# Create a member with same email
{:ok, member} =
Membership.create_member(%{
first_name: "John",
last_name: "Doe",
email: "duplicate@example.com"
})
# Create a user to link with the member
{:ok, user} =
Accounts.create_user(%{
email: "user@example.com"
})
# Link member to user - should succeed because member.email will be overridden
{:ok, updated_member} =
Membership.update_member(member, %{
user: %{id: user.id}
})
# Member email should now be the same as user email
{:ok, member_reloaded} = Ash.get(Mv.Membership.Member, updated_member.id)
assert to_string(member_reloaded.email) == "user@example.com"
end
end
describe "Email syncing" do
test "member email syncs to linked user email without validation error" do
# Create a user
{:ok, user} =
Accounts.create_user(%{
email: "user@example.com"
})
# Create a member linked to this user
# The override change will set member.email = user.email automatically
{:ok, member} =
Membership.create_member(%{
first_name: "John",
last_name: "Doe",
email: "member@example.com",
user: %{id: user.id}
})
# Member email should have been overridden to user email
# This happens through our sync mechanism, which should NOT trigger
# the "email already used" validation because it's the same user
{:ok, member_after_link} = Ash.get(Mv.Membership.Member, member.id)
assert member_after_link.email == "user@example.com"
end
test "user email syncs to linked member without validation error" do
# Create a member
{:ok, member} =
Membership.create_member(%{
first_name: "John",
last_name: "Doe",
email: "member@example.com"
})
# Create a user linked to this member
# The override change will set member.email = user.email automatically
{:ok, _user} =
Accounts.create_user(%{
email: "user@example.com",
member: %{id: member.id}
})
# Member email should have been overridden to user email
# This happens through our sync mechanism, which should NOT trigger
# the "email already used" validation because it's the same member
{:ok, member_after_link} = Ash.get(Mv.Membership.Member, member.id)
assert member_after_link.email == "user@example.com"
end
test "two unlinked users cannot have the same email" do
# Create first user
{:ok, _user1} =
Accounts.create_user(%{
email: "duplicate@example.com"
})
# Try to create second user with same email
result =
Accounts.create_user(%{
email: "duplicate@example.com"
})
assert {:error, %Ash.Error.Invalid{}} = result
end
test "two unlinked members cannot have the same email (members have unique constraint)" do
# Create first member
{:ok, _member1} =
Membership.create_member(%{
first_name: "John",
last_name: "Doe",
email: "duplicate@example.com"
})
# Try to create second member with same email - should fail
result =
Membership.create_member(%{
first_name: "Jane",
last_name: "Smith",
email: "duplicate@example.com"
})
assert {:error, %Ash.Error.Invalid{}} = result
# Members DO have a unique email constraint at database level
end
end
end

View file

@ -0,0 +1,169 @@
defmodule Mv.Accounts.UserEmailSyncTest do
@moduledoc """
Tests for email synchronization from User to Member.
When a user and member are linked, email changes should sync bidirectionally.
User.email is the source of truth when linking occurs.
"""
use Mv.DataCase, async: false
alias Mv.Accounts
alias Mv.Membership
describe "User email synchronization to linked Member" do
@valid_user_attrs %{
email: "user@example.com"
}
@valid_member_attrs %{
first_name: "John",
last_name: "Doe",
email: "member@example.com"
}
test "updating user email syncs to linked member" do
# Create a member
{:ok, member} = Membership.create_member(@valid_member_attrs)
assert member.email == "member@example.com"
# Create a user linked to the member
{:ok, user} =
Accounts.create_user(Map.put(@valid_user_attrs, :member, %{id: member.id}))
# Verify initial state - member email should be overridden by user email
{:ok, member_after_link} = Ash.get(Mv.Membership.Member, member.id)
assert member_after_link.email == "user@example.com"
# Update user email
{:ok, updated_user} = Accounts.update_user(user, %{email: "newemail@example.com"})
assert to_string(updated_user.email) == "newemail@example.com"
# Verify member email was also updated
{:ok, synced_member} = Ash.get(Mv.Membership.Member, member.id)
assert synced_member.email == "newemail@example.com"
end
test "creating user linked to member overrides member email" do
# Create a member with their own email
{:ok, member} = Membership.create_member(@valid_member_attrs)
assert member.email == "member@example.com"
# Create a user linked to this member
{:ok, user} =
Accounts.create_user(Map.put(@valid_user_attrs, :member, %{id: member.id}))
assert to_string(user.email) == "user@example.com"
assert user.member_id == member.id
# Verify member email was overridden with user email
{:ok, updated_member} = Ash.get(Mv.Membership.Member, member.id)
assert updated_member.email == "user@example.com"
end
test "linking user to existing member syncs user email to member" do
# Create a standalone member
{:ok, member} = Membership.create_member(@valid_member_attrs)
assert member.email == "member@example.com"
# Create a standalone user
{:ok, user} = Accounts.create_user(@valid_user_attrs)
assert to_string(user.email) == "user@example.com"
assert user.member_id == nil
# Link the user to the member
{:ok, linked_user} = Accounts.update_user(user, %{member: %{id: member.id}})
assert linked_user.member_id == member.id
# Verify member email was overridden with user email
{:ok, synced_member} = Ash.get(Mv.Membership.Member, member.id)
assert synced_member.email == "user@example.com"
end
test "updating user email when no member linked does not error" do
# Create a standalone user without member link
{:ok, user} = Accounts.create_user(@valid_user_attrs)
assert to_string(user.email) == "user@example.com"
assert user.member_id == nil
# Update user email - should work fine without error
{:ok, updated_user} = Accounts.update_user(user, %{email: "newemail@example.com"})
assert to_string(updated_user.email) == "newemail@example.com"
assert updated_user.member_id == nil
end
test "unlinking user from member does not sync email" do
# Create member
{:ok, member} = Membership.create_member(@valid_member_attrs)
# Create user linked to member
{:ok, user} =
Accounts.create_user(Map.put(@valid_user_attrs, :member, %{id: member.id}))
assert user.member_id == member.id
# Verify member email was synced
{:ok, synced_member} = Ash.get(Mv.Membership.Member, member.id)
assert synced_member.email == "user@example.com"
# Unlink user from member
{:ok, unlinked_user} = Accounts.update_user(user, %{member: nil})
assert unlinked_user.member_id == nil
# Member email should remain unchanged after unlinking
{:ok, member_after_unlink} = Ash.get(Mv.Membership.Member, member.id)
assert member_after_unlink.email == "user@example.com"
end
end
describe "AshAuthentication compatibility" do
test "AshAuthentication password strategy still works with email" do
# This test ensures that the email field remains accessible for password auth
email = "test@example.com"
password = "securepassword123"
# Create user with password strategy (simulating registration)
{:ok, user} =
Mv.Accounts.User
|> Ash.Changeset.for_create(:register_with_password, %{
email: email,
password: password
})
|> Ash.create()
assert to_string(user.email) == email
assert user.hashed_password != nil
# Verify we can sign in with email
{:ok, signed_in_user} =
Mv.Accounts.User
|> Ash.Query.for_read(:sign_in_with_password, %{
email: email,
password: password
})
|> Ash.read_one()
assert signed_in_user.id == user.id
assert to_string(signed_in_user.email) == email
end
test "AshAuthentication OIDC strategy still works with email" do
# This test ensures the OIDC flow can still set email
user_info = %{
"preferred_username" => "oidc@example.com",
"sub" => "oidc-user-123"
}
oauth_tokens = %{"access_token" => "mock_token"}
# Simulate OIDC registration
{:ok, user} =
Mv.Accounts.User
|> Ash.Changeset.for_create(:register_with_rauthy, %{
user_info: user_info,
oauth_tokens: oauth_tokens
})
|> Ash.create()
assert to_string(user.email) == "oidc@example.com"
assert user.oidc_id == "oidc-user-123"
end
end
end

View file

@ -0,0 +1,127 @@
defmodule Mv.Membership.MemberEmailSyncTest do
@moduledoc """
Tests for email synchronization from Member to User.
When a member and user are linked, email changes should sync bidirectionally.
User.email is the source of truth when linking occurs.
"""
use Mv.DataCase, async: false
alias Mv.Accounts
alias Mv.Membership
describe "Member email synchronization to linked User" do
@valid_user_attrs %{
email: "user@example.com"
}
@valid_member_attrs %{
first_name: "John",
last_name: "Doe",
email: "member@example.com"
}
test "updating member email syncs to linked user" do
# Create a user
{:ok, user} = Accounts.create_user(@valid_user_attrs)
assert to_string(user.email) == "user@example.com"
# Create a member linked to the user
{:ok, member} =
Membership.create_member(Map.put(@valid_member_attrs, :user, %{id: user.id}))
# Verify initial state - member email should be overridden by user email
{:ok, member_after_create} = Ash.get(Mv.Membership.Member, member.id)
assert member_after_create.email == "user@example.com"
# Update member email
{:ok, updated_member} =
Membership.update_member(member, %{email: "newmember@example.com"})
assert updated_member.email == "newmember@example.com"
# Verify user email was also updated
{:ok, synced_user} = Ash.get(Mv.Accounts.User, user.id)
assert to_string(synced_user.email) == "newmember@example.com"
end
test "creating member linked to user syncs user email to member" do
# Create a user with their own email
{:ok, user} = Accounts.create_user(@valid_user_attrs)
assert to_string(user.email) == "user@example.com"
# Create a member linked to this user
{:ok, member} =
Membership.create_member(Map.put(@valid_member_attrs, :user, %{id: user.id}))
# Member should have been created with user's email (user is source of truth)
assert member.email == "user@example.com"
# Verify the link
{:ok, loaded_member} = Ash.get(Mv.Membership.Member, member.id, load: [:user])
assert loaded_member.user.id == user.id
end
test "linking member to existing user syncs user email to member" do
# Create a standalone user
{:ok, user} = Accounts.create_user(@valid_user_attrs)
assert to_string(user.email) == "user@example.com"
# Create a standalone member
{:ok, member} = Membership.create_member(@valid_member_attrs)
assert member.email == "member@example.com"
# Link the member to the user
{:ok, linked_member} = Membership.update_member(member, %{user: %{id: user.id}})
# Verify the link
{:ok, loaded_member} = Ash.get(Mv.Membership.Member, linked_member.id, load: [:user])
assert loaded_member.user.id == user.id
# Verify member email was overridden with user email
assert loaded_member.email == "user@example.com"
end
test "updating member email when no user linked does not error" do
# Create a standalone member without user link
{:ok, member} = Membership.create_member(@valid_member_attrs)
assert member.email == "member@example.com"
# Load to verify no user link
{:ok, loaded_member} = Ash.get(Mv.Membership.Member, member.id, load: [:user])
assert loaded_member.user == nil
# Update member email - should work fine without error
{:ok, updated_member} =
Membership.update_member(member, %{email: "newemail@example.com"})
assert updated_member.email == "newemail@example.com"
end
test "unlinking member from user does not sync email" do
# Create user
{:ok, user} = Accounts.create_user(@valid_user_attrs)
# Create member linked to user
{:ok, member} =
Membership.create_member(Map.put(@valid_member_attrs, :user, %{id: user.id}))
# Verify member email was synced to user email
{:ok, synced_member} = Ash.get(Mv.Membership.Member, member.id)
assert synced_member.email == "user@example.com"
# Verify link exists
{:ok, loaded_member} = Ash.get(Mv.Membership.Member, member.id, load: [:user])
assert loaded_member.user != nil
# Unlink member from user
{:ok, unlinked_member} = Membership.update_member(member, %{user: nil})
# Verify unlink
{:ok, loaded_unlinked} = Ash.get(Mv.Membership.Member, unlinked_member.id, load: [:user])
assert loaded_unlinked.user == nil
# User email should remain unchanged after unlinking
{:ok, user_after_unlink} = Ash.get(Mv.Accounts.User, user.id)
assert to_string(user_after_unlink.email) == "user@example.com"
end
end
end

View file

@ -18,14 +18,14 @@ defmodule MvWeb.Components.SearchBarComponentTest do
html = html =
view view
|> element("form[role=search]") |> element("form[role=search]")
|> render_change(%{"query" => "Friedrich"}) |> render_submit(%{"query" => "Friedrich"})
refute html =~ "Greta" refute html =~ "Greta"
html = html =
view view
|> element("form[role=search]") |> element("form[role=search]")
|> render_change(%{"query" => "Greta"}) |> render_submit(%{"query" => "Greta"})
refute html =~ "Friedrich" refute html =~ "Friedrich"
end end

View file

@ -0,0 +1,319 @@
defmodule MvWeb.Components.SortHeaderComponentTest do
use MvWeb.ConnCase, async: true
import Phoenix.LiveViewTest
describe "rendering" do
test "renders with correct attributes", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Test that the component renders with correct attributes
assert has_element?(view, "[data-testid='first_name']")
assert has_element?(view, "button[phx-value-field='city']")
assert has_element?(view, "button[phx-value-field='first_name']", "First name")
end
test "renders all sortable headers", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
sortable_fields = [
:first_name,
:email,
:street,
:house_number,
:postal_code,
:city,
:phone_number,
:join_date
]
for field <- sortable_fields do
assert has_element?(view, "button[phx-value-field='#{field}']")
end
end
test "renders correct labels", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Test specific labels
assert has_element?(view, "button[phx-value-field='first_name']", "First name")
assert has_element?(view, "button[phx-value-field='email']", "Email")
assert has_element?(view, "button[phx-value-field='city']", "City")
end
end
describe "sort icons" do
test "shows neutral icon for specific field when not sorted", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# The neutral icon has the opcity class we can test for
# Test that EMAIL field specifically shows neutral icon
assert has_element?(view, "[data-testid='email'] .opacity-40")
# Test that CITY field specifically shows neutral icon
assert has_element?(view, "[data-testid='city'] .opacity-40")
end
test "shows ascending icon for specific field when sorted ascending", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, html} = live(conn, "/members?query=&sort_field=city&sort_order=asc")
# Test that FIRST_NAME field specifically shows ascending icon
# Test CSS classes - no opacity for active state
refute has_element?(view, "[data-testid='city'] .opacity-40")
# Test that OTHER fields still show neutral icons
assert has_element?(view, "[data-testid='first_name'] .opacity-40")
# Test HTML content - should contain chevronup AND chevron up down
assert html =~ "hero-chevron-up"
assert html =~ "hero-chevron-up-down"
# Count occurrences to ensure only one ascending icon
up_count = html |> String.split("hero-chevron-up ") |> length() |> Kernel.-(1)
# Should be exactly one chevronup icon
assert up_count == 1
end
test "shows descending icon for specific field when sorted descending", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members?query=&sort_field=email&sort_order=desc")
# Count occurrences to ensure only one descending icon
down_count = html |> String.split("hero-chevron-down ") |> length() |> Kernel.-(1)
# Should be exactly one chevrondown icon
assert down_count == 1
end
test "multiple fields can have different icon states", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members?query=&sort_field=city&sort_order=asc")
# CITY field should be active (ascending)
refute has_element?(view, "[data-testid='city'] .opacity-40")
# All other fields should be neutral
assert has_element?(view, "[data-testid='first_name'] .opacity-40")
assert has_element?(view, "[data-testid='email'] .opacity-40")
assert has_element?(view, "[data-testid='street'] .opacity-40")
assert has_element?(view, "[data-testid='house_number'] .opacity-40")
assert has_element?(view, "[data-testid='postal_code'] .opacity-40")
assert has_element?(view, "[data-testid='phone_number'] .opacity-40")
assert has_element?(view, "[data-testid='join_date'] .opacity-40")
end
test "icon state changes correctly when clicking different fields", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Start: all fields neutral except first name as default
assert has_element?(view, "[data-testid='city'] .opacity-40")
refute has_element?(view, "[data-testid='first_name'] .opacity-40")
assert has_element?(view, "[data-testid='email'] .opacity-40")
# Click city - should become active
view
|> element("button[phx-value-field='city']")
|> render_click()
# city should be active, email should still be neutral
refute has_element?(view, "[data-testid='city'] .opacity-40")
assert has_element?(view, "[data-testid='email'] .opacity-40")
# Click email - should switch active field
view
|> element("button[phx-value-field='email']")
|> render_click()
# email should be active, city should be neutral again
refute has_element?(view, "[data-testid='email'] .opacity-40")
assert has_element?(view, "[data-testid='city'] .opacity-40")
end
test "specific field shows correct icon for each sort state", %{conn: conn} do
conn = conn_with_oidc_user(conn)
# Test EMAIL field specifically
{:ok, view, html_asc} = live(conn, "/members?sort_field=email&sort_order=asc")
assert html_asc =~ "hero-chevron-up"
refute has_element?(view, "[data-testid='email'] .opacity-40")
{:ok, view, html_desc} = live(conn, "/members?sort_field=email&sort_order=desc")
assert html_desc =~ "hero-chevron-down"
refute has_element?(view, "[data-testid='email'] .opacity-40")
{:ok, view, html_neutral} = live(conn, "/members")
assert html_neutral =~ "hero-chevron-up-down"
assert has_element?(view, "[data-testid='email'] .opacity-40")
end
test "icon distribution is correct for all fields", %{conn: conn} do
conn = conn_with_oidc_user(conn)
# Test neutral state - all fields except first name (default) should show neutral icons
{:ok, _view, html_neutral} = live(conn, "/members")
# Count neutral icons (should be 7 - one for each field)
neutral_count =
html_neutral |> String.split("hero-chevron-up-down") |> length() |> Kernel.-(1)
assert neutral_count == 7
# Count active icons (should be 1)
up_count = html_neutral |> String.split("hero-chevron-up ") |> length() |> Kernel.-(1)
down_count = html_neutral |> String.split("hero-chevron-down ") |> length() |> Kernel.-(1)
assert up_count == 1
assert down_count == 0
# Test ascending state - one field active, others neutral
{:ok, _view, html_asc} = live(conn, "/members?sort_field=first_name&sort_order=asc")
# Should have exactly 1 ascending icon and 7 neutral icons
up_count = html_asc |> String.split("hero-chevron-up ") |> length() |> Kernel.-(1)
neutral_count = html_asc |> String.split("hero-chevron-up-down") |> length() |> Kernel.-(1)
down_count = html_asc |> String.split("hero-chevron-down ") |> length() |> Kernel.-(1)
assert up_count == 1
assert neutral_count == 7
assert down_count == 0
end
end
describe "accessibility" do
test "sets aria-label correctly for unsorted state", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Check aria-label for unsorted state
assert has_element?(view, "button[phx-value-field='city'][aria-label='Click to sort']")
end
test "sets aria-label correctly for ascending sort", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members?sort_field=first_name&sort_order=asc")
# Check aria-label for ascending sort
assert has_element?(view, "button[phx-value-field='first_name'][aria-label='ascending']")
end
test "sets aria-label correctly for descending sort", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members?sort_field=first_name&sort_order=desc")
# Check aria-label for descending sort
assert has_element?(view, "button[phx-value-field='first_name'][aria-label='descending']")
end
test "includes tooltip with correct aria-label", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members?sort_field=first_name&sort_order=asc")
# Check that tooltip div exists with correct data-tip
assert has_element?(view, "[data-testid='first_name']")
assert has_element?(view, "button[phx-value-field='first_name'][aria-label='ascending']")
end
test "aria-labels work for all sortable fields", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members?sort_field=email&sort_order=desc")
# Test aria-labels for different fields
assert has_element?(view, "button[phx-value-field='email'][aria-label='descending']")
assert has_element?(
view,
"button[phx-value-field='first_name'][aria-label='Click to sort']"
)
assert has_element?(view, "button[phx-value-field='city'][aria-label='Click to sort']")
end
end
describe "component behavior" do
test "clicking sends sort message to parent", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Click on the first name sort header
view
|> element("button[phx-value-field='first_name']")
|> render_click()
# The component should send a message to the parent LiveView
# This is tested indirectly through the URL change in integration tests
end
test "component handles different field types correctly", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Test that different field types render correctly
assert has_element?(view, "button[phx-value-field='first_name']")
assert has_element?(view, "button[phx-value-field='email']")
assert has_element?(view, "button[phx-value-field='join_date']")
end
end
describe "edge cases" do
test "handles invalid sort field gracefully", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, html} = live(conn, "/members?sort_field=invalid_field&sort_order=asc")
# Should not crash and should default sorting for first name
assert html =~ "hero-chevron-up-down"
refute has_element?(view, "[data-testid='first_name'] .opacity-40")
end
test "handles invalid sort order gracefully", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, html} = live(conn, "/members?sort_field=first_name&sort_order=invalid")
# Should default to ascending
assert html =~ "hero-chevron-up"
refute has_element?(view, "[data-testid='first_name'] [aria-label='ascending']")
end
test "handles empty sort parameters", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, html} = live(conn, "/members?sort_field=&sort_order=")
# Should show neutral icons
assert html =~ "hero-chevron-up-down"
assert has_element?(view, "[data-testid='city'] .opacity-40")
end
end
describe "icon state transitions" do
test "icon changes when sorting state changes", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Start with neutral state
assert has_element?(view, "[data-testid='city'] .opacity-40")
# Click to sort ascending
view
|> element("button[phx-value-field='city']")
|> render_click()
# Should now be ascending (no opacity class)
refute has_element?(view, "[data-testid='city'] .opacity-40")
end
test "multiple fields can be tested for icon states", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, html} = live(conn, "/members?sort_field=email&sort_order=desc")
# Email should be active (descending)
assert html =~ "hero-chevron-down"
refute has_element?(view, "[data-testid='email'] .opacity-40")
# Other fields should be neutral
assert has_element?(view, "[data-testid='first_name'] .opacity-40")
assert has_element?(view, "[data-testid='city'] .opacity-40")
end
end
end

View file

@ -56,7 +56,6 @@ defmodule MvWeb.MemberLive.IndexTest do
test "shows translated flash message after creating a member in English", %{conn: conn} do test "shows translated flash message after creating a member in English", %{conn: conn} do
conn = conn_with_oidc_user(conn) conn = conn_with_oidc_user(conn)
conn = Plug.Test.init_test_session(conn, locale: "en")
{:ok, form_view, _html} = live(conn, "/members/new") {:ok, form_view, _html} = live(conn, "/members/new")
form_data = %{ form_data = %{
@ -75,6 +74,143 @@ defmodule MvWeb.MemberLive.IndexTest do
assert has_element?(index_view, "#flash-group", "Member create successfully") assert has_element?(index_view, "#flash-group", "Member create successfully")
end end
describe "sorting integration" do
test "clicking a column header toggles sort order and updates the URL", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# The component data test ids are built with the name of the field
# First click should sort ASC
view
|> element("[data-testid='email']")
|> render_click()
# The LiveView pushes a patch with the new query params
assert_patch(view, "/members?query=&sort_field=email&sort_order=asc")
# Second click toggles to DESC
view
|> element("[data-testid='email']")
|> render_click()
assert_patch(view, "/members?query=&sort_field=email&sort_order=desc")
end
test "clicking different column header resets order to ascending", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members?sort_field=email&sort_order=desc")
# Click on a different column
view
|> element("[data-testid='first_name']")
|> render_click()
assert_patch(view, "/members?query=&sort_field=first_name&sort_order=asc")
end
test "all sortable columns work correctly", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# default ascending sorting with first name
assert has_element?(view, "[data-testid='first_name'][aria-label='ascending']")
sortable_fields = [
:email,
:street,
:house_number,
:postal_code,
:city,
:phone_number,
:join_date
]
for field <- sortable_fields do
view
|> element("[data-testid='#{field}']")
|> render_click()
assert_patch(view, "/members?query=&sort_field=#{field}&sort_order=asc")
end
end
test "sorting works with search query", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members?query=test")
view
|> element("[data-testid='email']")
|> render_click()
assert_patch(view, "/members?query=test&sort_field=email&sort_order=asc")
end
test "sorting maintains search query when toggling order", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members?query=test&sort_field=email&sort_order=asc")
view
|> element("[data-testid='email']")
|> render_click()
assert_patch(view, "/members?query=test&sort_field=email&sort_order=desc")
end
end
describe "URL param handling" do
test "handle_params reads sort query and applies it", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members?query=&sort_field=email&sort_order=desc")
# Check that the sort state is correctly applied
assert has_element?(view, "[data-testid='email'][aria-label='descending']")
end
test "handle_params handles invalid sort field gracefully", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members?query=&sort_field=invalid_field&sort_order=asc")
# Should not crash and should show default first name order
assert has_element?(view, "[data-testid='first_name'][aria-label='ascending']")
end
test "handle_params preserves search query with sort params", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members?query=test&sort_field=email&sort_order=desc")
# Both search and sort should be preserved
assert has_element?(view, "[data-testid='email'][aria-label='descending']")
end
end
describe "search and sort integration" do
test "search maintains sort state", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members?query=&sort_field=email&sort_order=desc")
# Perform search
view
|> element("[data-testid='search-input']")
|> render_change(%{value: "test"})
# Sort state should be maintained
assert has_element?(view, "[data-testid='email'][aria-label='descending']")
end
test "sort maintains search state", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members?query=test&sort_field=email&sort_order=asc")
# Perform sort
view
|> element("[data-testid='email']")
|> render_click()
# Search state should be maintained
assert_patch(view, "/members?query=test&sort_field=email&sort_order=desc")
end
end
test "handle_info(:search_changed) updates assigns with search results", %{conn: conn} do test "handle_info(:search_changed) updates assigns with search results", %{conn: conn} do
conn = conn_with_oidc_user(conn) conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members") {:ok, view, _html} = live(conn, "/members")

46
test/seeds_test.exs Normal file
View file

@ -0,0 +1,46 @@
defmodule Mv.SeedsTest do
use Mv.DataCase, async: false
describe "Seeds script" do
test "runs successfully without errors" do
# Run the seeds script - should not raise any errors
assert Code.eval_file("priv/repo/seeds.exs")
# Basic smoke test: ensure some data was created
{:ok, users} = Ash.read(Mv.Accounts.User)
{:ok, members} = Ash.read(Mv.Membership.Member)
{:ok, property_types} = Ash.read(Mv.Membership.PropertyType)
assert length(users) > 0, "Seeds should create at least one user"
assert length(members) > 0, "Seeds should create at least one member"
assert length(property_types) > 0, "Seeds should create at least one property type"
end
test "can be run multiple times (idempotent)" do
# Run seeds first time
assert Code.eval_file("priv/repo/seeds.exs")
# Count records
{:ok, users_count_1} = Ash.read(Mv.Accounts.User)
{:ok, members_count_1} = Ash.read(Mv.Membership.Member)
{:ok, property_types_count_1} = Ash.read(Mv.Membership.PropertyType)
# Run seeds second time - should not raise errors
assert Code.eval_file("priv/repo/seeds.exs")
# Count records again - should be the same (upsert, not duplicate)
{:ok, users_count_2} = Ash.read(Mv.Accounts.User)
{:ok, members_count_2} = Ash.read(Mv.Membership.Member)
{:ok, property_types_count_2} = Ash.read(Mv.Membership.PropertyType)
assert length(users_count_1) == length(users_count_2),
"Users count should remain same after re-running seeds"
assert length(members_count_1) == length(members_count_2),
"Members count should remain same after re-running seeds"
assert length(property_types_count_1) == length(property_types_count_2),
"PropertyTypes count should remain same after re-running seeds"
end
end
end