Compare commits
21 commits
660d587cc1
...
8104451d35
| Author | SHA1 | Date | |
|---|---|---|---|
| 8104451d35 | |||
| bb362e1636 | |||
| 41e3a52482 | |||
| b71df98ba2 | |||
| eb42b9fe0a | |||
| 85e1f370f6 | |||
| 9d98ec2494 | |||
| 017ca8b32c | |||
| 3cfae95b1e | |||
| c3502a326e | |||
| d9e48a37d2 | |||
| 687d653fb7 | |||
| 899039b3ee | |||
| 37fcc26b22 | |||
| 1495ef4592 | |||
| 001fca1d16 | |||
| 2693f67d33 | |||
| 7522724945 | |||
| 39afaf3999 | |||
| 5a0a261cd6 | |||
| 91c5e17994 |
27 changed files with 2391 additions and 205 deletions
49
docs/email-sync.md
Normal file
49
docs/email-sync.md
Normal 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 |
|
||||
|
||||
|
||||
|
|
@ -66,14 +66,17 @@ defmodule Mv.Accounts.User do
|
|||
end
|
||||
|
||||
actions do
|
||||
# Default actions kept 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.
|
||||
# Default actions for framework/tooling integration:
|
||||
# - :read -> Standard read used across the app and by admin tooling.
|
||||
# - :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:
|
||||
# - 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
|
||||
# complex checks that are not supported in atomic operations.
|
||||
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
|
||||
|
||||
create :create_user do
|
||||
|
|
@ -111,6 +120,9 @@ defmodule Mv.Accounts.User do
|
|||
# If no member provided, that's fine (optional relationship)
|
||||
on_missing: :ignore
|
||||
)
|
||||
|
||||
# Sync user email to member when linking (User → Member)
|
||||
change Mv.EmailSync.Changes.SyncUserEmailToMember
|
||||
end
|
||||
|
||||
update :update_user do
|
||||
|
|
@ -137,6 +149,12 @@ defmodule Mv.Accounts.User do
|
|||
# If no member provided, remove existing relationship (allows member removal)
|
||||
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
|
||||
|
||||
# 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(:oidc_id, user_info["sub"] || user_info["id"])
|
||||
end
|
||||
|
||||
# Sync user email to member when linking (User → Member)
|
||||
change Mv.EmailSync.Changes.SyncUserEmailToMember
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -195,6 +216,10 @@ defmodule Mv.Accounts.User do
|
|||
where: [action_is([:register_with_password, :admin_set_password])],
|
||||
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)
|
||||
# This ensures consistency between User and Member email validation
|
||||
validate fn changeset, _ ->
|
||||
|
|
@ -255,6 +280,13 @@ defmodule Mv.Accounts.User do
|
|||
attributes do
|
||||
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
|
||||
allow_nil? false
|
||||
public? true
|
||||
|
|
|
|||
|
|
@ -48,6 +48,12 @@ defmodule Mv.Membership.Member do
|
|||
# If no user provided, that's fine (optional relationship)
|
||||
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
|
||||
|
||||
update :update_member do
|
||||
|
|
@ -89,6 +95,18 @@ defmodule Mv.Membership.Member do
|
|||
# If no user provided, remove existing relationship (allows user removal)
|
||||
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
|
||||
|
||||
|
|
@ -100,6 +118,10 @@ defmodule Mv.Membership.Member do
|
|||
validate present(:last_name)
|
||||
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
|
||||
# This validation prevents "stealing" users from other members by checking
|
||||
# if the target user is already linked to a different member
|
||||
|
|
@ -189,6 +211,13 @@ defmodule Mv.Membership.Member do
|
|||
constraints min_length: 1
|
||||
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
|
||||
allow_nil? false
|
||||
constraints min_length: 5, max_length: 254
|
||||
|
|
|
|||
|
|
@ -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
|
||||
37
lib/mv/email_sync/changes/sync_member_email_to_user.ex
Normal file
37
lib/mv/email_sync/changes/sync_member_email_to_user.ex
Normal 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
|
||||
62
lib/mv/email_sync/changes/sync_user_email_to_member.ex
Normal file
62
lib/mv/email_sync/changes/sync_user_email_to_member.ex
Normal 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
|
||||
93
lib/mv/email_sync/helpers.ex
Normal file
93
lib/mv/email_sync/helpers.ex
Normal 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
|
||||
40
lib/mv/email_sync/loader.ex
Normal file
40
lib/mv/email_sync/loader.ex
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
@ -8,10 +8,10 @@ defmodule MvWeb.Components.SearchBarComponent do
|
|||
use MvWeb, :live_component
|
||||
|
||||
@impl true
|
||||
def update(_assigns, socket) do
|
||||
def update(%{query: query}, socket) do
|
||||
socket =
|
||||
socket
|
||||
|> assign_new(:query, fn -> "" end)
|
||||
|> assign_new(:query, fn -> query || "" end)
|
||||
|> assign_new(:placeholder, fn -> gettext("Search...") end)
|
||||
|
||||
{:ok, socket}
|
||||
|
|
@ -20,7 +20,7 @@ defmodule MvWeb.Components.SearchBarComponent do
|
|||
@impl true
|
||||
def render(assigns) do
|
||||
~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">
|
||||
<svg
|
||||
class="h-[1em] opacity-50"
|
||||
|
|
@ -44,6 +44,9 @@ defmodule MvWeb.Components.SearchBarComponent do
|
|||
placeholder={@placeholder}
|
||||
value={@query}
|
||||
name="query"
|
||||
data-testid="search-input"
|
||||
phx-change="search"
|
||||
phx-target={@myself}
|
||||
phx-debounce="300"
|
||||
/>
|
||||
</label>
|
||||
|
|
|
|||
64
lib/mv_web/live/components/sort_header_component.ex
Normal file
64
lib/mv_web/live/components/sort_header_component.ex
Normal 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
|
||||
|
|
@ -2,49 +2,26 @@ defmodule MvWeb.MemberLive.Index do
|
|||
use MvWeb, :live_view
|
||||
import Ash.Expr
|
||||
import Ash.Query
|
||||
import MvWeb.TableComponents
|
||||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
members = Ash.read!(Mv.Membership.Member)
|
||||
sorted = Enum.sort_by(members, & &1.first_name)
|
||||
socket =
|
||||
socket
|
||||
|> assign(:page_title, gettext("Members"))
|
||||
|> assign(:query, "")
|
||||
|> assign_new(:sort_field, fn -> :first_name end)
|
||||
|> assign_new(:sort_order, fn -> :asc end)
|
||||
|> assign(:selected_members, [])
|
||||
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(:page_title, gettext("Members"))
|
||||
|> assign(:query, "")
|
||||
|> assign(:sort_field, :first_name)
|
||||
|> assign(:sort_order, :asc)
|
||||
|> assign(:members, sorted)
|
||||
|> assign(:selected_members, [])}
|
||||
end
|
||||
|
||||
# -----------------------------------------------------------------
|
||||
# Receive messages from any toolbar component
|
||||
# -----------------------------------------------------------------
|
||||
|
||||
# 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)}
|
||||
# We call handle params to use the query from the URL
|
||||
{:ok, socket}
|
||||
end
|
||||
|
||||
# -----------------------------------------------------------------
|
||||
# Handle Events
|
||||
# -----------------------------------------------------------------
|
||||
|
||||
# Delete a member
|
||||
@impl true
|
||||
def handle_event("delete", %{"id" => id}, socket) do
|
||||
member = Ash.get!(Mv.Membership.Member, id)
|
||||
|
|
@ -67,32 +44,7 @@ defmodule MvWeb.MemberLive.Index do
|
|||
{:noreply, assign(socket, :selected_members, selected)}
|
||||
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
|
||||
def handle_event("select_all", _params, socket) do
|
||||
members = socket.assigns.members
|
||||
|
|
@ -109,8 +61,235 @@ defmodule MvWeb.MemberLive.Index do
|
|||
{:noreply, assign(socket, :selected_members, selected)}
|
||||
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(:desc), do: :asc
|
||||
defp sort_fun(:asc), do: &<=/2
|
||||
defp sort_fun(:desc), do: &>=/2
|
||||
defp toggle_order(nil), do: :asc
|
||||
|
||||
# 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
|
||||
|
|
|
|||
|
|
@ -52,23 +52,139 @@
|
|||
<:col
|
||||
:let={member}
|
||||
label={
|
||||
sort_button(%{
|
||||
field: :first_name,
|
||||
label: gettext("Name"),
|
||||
sort_field: @sort_field,
|
||||
sort_order: @sort_order
|
||||
})
|
||||
~H"""
|
||||
<.live_component
|
||||
module={MvWeb.Components.SortHeaderComponent}
|
||||
id={:sort_first_name}
|
||||
field={:first_name}
|
||||
label={gettext("First name")}
|
||||
sort_field={@sort_field}
|
||||
sort_order={@sort_order}
|
||||
/>
|
||||
"""
|
||||
}
|
||||
>
|
||||
{member.first_name} {member.last_name}
|
||||
</:col>
|
||||
<:col :let={member} label={gettext("Email")}>{member.email}</:col>
|
||||
<:col :let={member} label={gettext("Street")}>{member.street}</:col>
|
||||
<:col :let={member} label={gettext("House Number")}>{member.house_number}</:col>
|
||||
<:col :let={member} label={gettext("Postal Code")}>{member.postal_code}</:col>
|
||||
<:col :let={member} label={gettext("City")}>{member.city}</:col>
|
||||
<:col :let={member} label={gettext("Phone Number")}>{member.phone_number}</:col>
|
||||
<:col :let={member} label={gettext("Join Date")}>{member.join_date}</:col>
|
||||
<:col
|
||||
:let={member}
|
||||
label={
|
||||
~H"""
|
||||
<.live_component
|
||||
module={MvWeb.Components.SortHeaderComponent}
|
||||
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}>
|
||||
<div class="sr-only">
|
||||
|
|
|
|||
|
|
@ -61,3 +61,6 @@ msgstr "Anmelden..."
|
|||
|
||||
msgid "Your password has successfully been reset"
|
||||
msgstr "Das Passwort wurde erfolgreich zurückgesetzt"
|
||||
|
||||
#~ msgid "Sign in with Rauthy"
|
||||
#~ msgstr "Anmelden mit der Vereinscloud"
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ msgstr ""
|
|||
msgid "Actions"
|
||||
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
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Are you sure?"
|
||||
|
|
@ -28,19 +28,19 @@ msgid "Attempting to reconnect"
|
|||
msgstr "Verbindung wird wiederhergestellt"
|
||||
|
||||
#: lib/mv_web/live/member_live/form.ex:25
|
||||
#: lib/mv_web/live/member_live/index.html.heex:69
|
||||
#: lib/mv_web/live/member_live/show.ex:37
|
||||
#: lib/mv_web/live/member_live/index.html.heex:138
|
||||
#: lib/mv_web/live/member_live/show.ex:36
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "City"
|
||||
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
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Delete"
|
||||
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/index.html.heex:59
|
||||
#, elixir-autogen, elixir-format
|
||||
|
|
@ -54,8 +54,8 @@ msgid "Edit Member"
|
|||
msgstr "Mitglied bearbeiten"
|
||||
|
||||
#: 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/show.ex:28
|
||||
#: lib/mv_web/live/member_live/index.html.heex:70
|
||||
#: lib/mv_web/live/member_live/show.ex:27
|
||||
#: 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/show.ex:25
|
||||
|
|
@ -70,8 +70,8 @@ msgid "First Name"
|
|||
msgstr "Vorname"
|
||||
|
||||
#: 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/show.ex:34
|
||||
#: lib/mv_web/live/member_live/index.html.heex:172
|
||||
#: lib/mv_web/live/member_live/show.ex:33
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Join Date"
|
||||
msgstr "Beitrittsdatum"
|
||||
|
|
@ -87,7 +87,7 @@ msgstr "Nachname"
|
|||
msgid "New Member"
|
||||
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
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Show"
|
||||
|
|
@ -127,8 +127,8 @@ msgid "Exit Date"
|
|||
msgstr "Austrittsdatum"
|
||||
|
||||
#: 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/show.ex:39
|
||||
#: lib/mv_web/live/member_live/index.html.heex:104
|
||||
#: lib/mv_web/live/member_live/show.ex:38
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "House Number"
|
||||
msgstr "Hausnummer"
|
||||
|
|
@ -146,15 +146,15 @@ msgid "Paid"
|
|||
msgstr "Bezahlt"
|
||||
|
||||
#: 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/show.ex:33
|
||||
#: lib/mv_web/live/member_live/index.html.heex:155
|
||||
#: lib/mv_web/live/member_live/show.ex:32
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Phone Number"
|
||||
msgstr "Telefonnummer"
|
||||
|
||||
#: 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/show.ex:40
|
||||
#: lib/mv_web/live/member_live/index.html.heex:121
|
||||
#: lib/mv_web/live/member_live/show.ex:39
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Postal Code"
|
||||
msgstr "Postleitzahl"
|
||||
|
|
@ -173,8 +173,8 @@ msgid "Saving..."
|
|||
msgstr "Speichern..."
|
||||
|
||||
#: 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/show.ex:38
|
||||
#: lib/mv_web/live/member_live/index.html.heex:87
|
||||
#: lib/mv_web/live/member_live/show.ex:37
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Street"
|
||||
msgstr "Straße"
|
||||
|
|
@ -317,14 +317,13 @@ msgstr "Benutzer*innen auflisten"
|
|||
msgid "Member"
|
||||
msgstr "Mitglied"
|
||||
|
||||
#: lib/mv_web/components/layouts/navbar.ex:19
|
||||
#: lib/mv_web/live/member_live/index.ex:14
|
||||
#: lib/mv_web/components/layouts/navbar.ex:14
|
||||
#: lib/mv_web/live/member_live/index.ex:8
|
||||
#: lib/mv_web/live/member_live/index.html.heex:3
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Members"
|
||||
msgstr "Mitglieder"
|
||||
|
||||
#: lib/mv_web/live/member_live/index.html.heex:57
|
||||
#: lib/mv_web/live/property_type_live/form.ex:16
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Name"
|
||||
|
|
@ -469,11 +468,13 @@ msgid "Value type"
|
|||
msgstr "Wertetyp"
|
||||
|
||||
#: lib/mv_web/components/table_components.ex:30
|
||||
#: lib/mv_web/live/components/sort_header_component.ex:55
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "ascending"
|
||||
msgstr "aufsteigend"
|
||||
|
||||
#: lib/mv_web/components/table_components.ex:30
|
||||
#: lib/mv_web/live/components/sort_header_component.ex:56
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "descending"
|
||||
msgstr "absteigend"
|
||||
|
|
@ -600,10 +601,15 @@ msgstr "Dunklen Modus umschalten"
|
|||
#: 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 ""
|
||||
msgid "Click to sort"
|
||||
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
|
||||
msgid "Users"
|
||||
msgstr "Benutzer*innen"
|
||||
msgid "First name"
|
||||
msgstr "Vorname"
|
||||
|
||||
#~ #: lib/mv_web/auth_overrides.ex:30
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "or"
|
||||
#~ msgstr "oder"
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ msgstr ""
|
|||
msgid "Actions"
|
||||
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
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Are you sure?"
|
||||
|
|
@ -29,19 +29,19 @@ msgid "Attempting to reconnect"
|
|||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/form.ex:25
|
||||
#: lib/mv_web/live/member_live/index.html.heex:69
|
||||
#: lib/mv_web/live/member_live/show.ex:37
|
||||
#: lib/mv_web/live/member_live/index.html.heex:138
|
||||
#: lib/mv_web/live/member_live/show.ex:36
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "City"
|
||||
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
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Delete"
|
||||
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/index.html.heex:59
|
||||
#, elixir-autogen, elixir-format
|
||||
|
|
@ -55,8 +55,8 @@ msgid "Edit Member"
|
|||
msgstr ""
|
||||
|
||||
#: 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/show.ex:28
|
||||
#: lib/mv_web/live/member_live/index.html.heex:70
|
||||
#: lib/mv_web/live/member_live/show.ex:27
|
||||
#: 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/show.ex:25
|
||||
|
|
@ -71,8 +71,8 @@ msgid "First Name"
|
|||
msgstr ""
|
||||
|
||||
#: 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/show.ex:34
|
||||
#: lib/mv_web/live/member_live/index.html.heex:172
|
||||
#: lib/mv_web/live/member_live/show.ex:33
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Join Date"
|
||||
msgstr ""
|
||||
|
|
@ -88,7 +88,7 @@ msgstr ""
|
|||
msgid "New Member"
|
||||
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
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Show"
|
||||
|
|
@ -128,8 +128,8 @@ msgid "Exit Date"
|
|||
msgstr ""
|
||||
|
||||
#: 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/show.ex:39
|
||||
#: lib/mv_web/live/member_live/index.html.heex:104
|
||||
#: lib/mv_web/live/member_live/show.ex:38
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "House Number"
|
||||
msgstr ""
|
||||
|
|
@ -147,15 +147,15 @@ msgid "Paid"
|
|||
msgstr ""
|
||||
|
||||
#: 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/show.ex:33
|
||||
#: lib/mv_web/live/member_live/index.html.heex:155
|
||||
#: lib/mv_web/live/member_live/show.ex:32
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Phone Number"
|
||||
msgstr ""
|
||||
|
||||
#: 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/show.ex:40
|
||||
#: lib/mv_web/live/member_live/index.html.heex:121
|
||||
#: lib/mv_web/live/member_live/show.ex:39
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Postal Code"
|
||||
msgstr ""
|
||||
|
|
@ -174,8 +174,8 @@ msgid "Saving..."
|
|||
msgstr ""
|
||||
|
||||
#: 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/show.ex:38
|
||||
#: lib/mv_web/live/member_live/index.html.heex:87
|
||||
#: lib/mv_web/live/member_live/show.ex:37
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Street"
|
||||
msgstr ""
|
||||
|
|
@ -318,14 +318,13 @@ msgstr ""
|
|||
msgid "Member"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/layouts/navbar.ex:19
|
||||
#: lib/mv_web/live/member_live/index.ex:14
|
||||
#: lib/mv_web/components/layouts/navbar.ex:14
|
||||
#: lib/mv_web/live/member_live/index.ex:8
|
||||
#: lib/mv_web/live/member_live/index.html.heex:3
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Members"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/index.html.heex:57
|
||||
#: lib/mv_web/live/property_type_live/form.ex:16
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Name"
|
||||
|
|
@ -470,11 +469,13 @@ msgid "Value type"
|
|||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/table_components.ex:30
|
||||
#: lib/mv_web/live/components/sort_header_component.ex:55
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "ascending"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/table_components.ex:30
|
||||
#: lib/mv_web/live/components/sort_header_component.ex:56
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "descending"
|
||||
msgstr ""
|
||||
|
|
@ -607,4 +608,12 @@ msgstr ""
|
|||
#: lib/mv_web/components/layouts/navbar.ex:20
|
||||
#, elixir-autogen, elixir-format
|
||||
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 ""
|
||||
|
|
|
|||
|
|
@ -58,3 +58,6 @@ msgstr ""
|
|||
|
||||
msgid "Your password has successfully been reset"
|
||||
msgstr ""
|
||||
|
||||
#~ msgid "Sign in with Rauthy"
|
||||
#~ msgstr "Sign in with Vereinscloud"
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ msgstr ""
|
|||
msgid "Actions"
|
||||
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
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Are you sure?"
|
||||
|
|
@ -29,19 +29,19 @@ msgid "Attempting to reconnect"
|
|||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/form.ex:25
|
||||
#: lib/mv_web/live/member_live/index.html.heex:69
|
||||
#: lib/mv_web/live/member_live/show.ex:37
|
||||
#: lib/mv_web/live/member_live/index.html.heex:138
|
||||
#: lib/mv_web/live/member_live/show.ex:36
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "City"
|
||||
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
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Delete"
|
||||
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/index.html.heex:59
|
||||
#, elixir-autogen, elixir-format
|
||||
|
|
@ -55,8 +55,8 @@ msgid "Edit Member"
|
|||
msgstr ""
|
||||
|
||||
#: 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/show.ex:28
|
||||
#: lib/mv_web/live/member_live/index.html.heex:70
|
||||
#: lib/mv_web/live/member_live/show.ex:27
|
||||
#: 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/show.ex:25
|
||||
|
|
@ -71,8 +71,8 @@ msgid "First Name"
|
|||
msgstr ""
|
||||
|
||||
#: 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/show.ex:34
|
||||
#: lib/mv_web/live/member_live/index.html.heex:172
|
||||
#: lib/mv_web/live/member_live/show.ex:33
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Join Date"
|
||||
msgstr ""
|
||||
|
|
@ -88,7 +88,7 @@ msgstr ""
|
|||
msgid "New Member"
|
||||
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
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Show"
|
||||
|
|
@ -128,8 +128,8 @@ msgid "Exit Date"
|
|||
msgstr ""
|
||||
|
||||
#: 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/show.ex:39
|
||||
#: lib/mv_web/live/member_live/index.html.heex:104
|
||||
#: lib/mv_web/live/member_live/show.ex:38
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "House Number"
|
||||
msgstr ""
|
||||
|
|
@ -147,15 +147,15 @@ msgid "Paid"
|
|||
msgstr ""
|
||||
|
||||
#: 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/show.ex:33
|
||||
#: lib/mv_web/live/member_live/index.html.heex:155
|
||||
#: lib/mv_web/live/member_live/show.ex:32
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Phone Number"
|
||||
msgstr ""
|
||||
|
||||
#: 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/show.ex:40
|
||||
#: lib/mv_web/live/member_live/index.html.heex:121
|
||||
#: lib/mv_web/live/member_live/show.ex:39
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Postal Code"
|
||||
msgstr ""
|
||||
|
|
@ -174,8 +174,8 @@ msgid "Saving..."
|
|||
msgstr ""
|
||||
|
||||
#: 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/show.ex:38
|
||||
#: lib/mv_web/live/member_live/index.html.heex:87
|
||||
#: lib/mv_web/live/member_live/show.ex:37
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Street"
|
||||
msgstr ""
|
||||
|
|
@ -318,14 +318,13 @@ msgstr ""
|
|||
msgid "Member"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/layouts/navbar.ex:19
|
||||
#: lib/mv_web/live/member_live/index.ex:14
|
||||
#: lib/mv_web/components/layouts/navbar.ex:14
|
||||
#: lib/mv_web/live/member_live/index.ex:8
|
||||
#: lib/mv_web/live/member_live/index.html.heex:3
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Members"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/index.html.heex:57
|
||||
#: lib/mv_web/live/property_type_live/form.ex:16
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Name"
|
||||
|
|
@ -470,11 +469,13 @@ msgid "Value type"
|
|||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/table_components.ex:30
|
||||
#: lib/mv_web/live/components/sort_header_component.ex:55
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "ascending"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/table_components.ex:30
|
||||
#: lib/mv_web/live/components/sort_header_component.ex:56
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "descending"
|
||||
msgstr ""
|
||||
|
|
@ -554,57 +555,17 @@ msgstr "Set Password"
|
|||
msgid "User will be created without a password. Check 'Set Password' to add one."
|
||||
msgstr "User will be created without a password. Check 'Set Password' to add one."
|
||||
|
||||
#: lib/mv_web/live/user_live/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
|
||||
msgid "Linked Member"
|
||||
msgid "First name"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/show.ex:41
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Linked User"
|
||||
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 ""
|
||||
#~ #: lib/mv_web/auth_overrides.ex:30
|
||||
#~ #, elixir-autogen, elixir-format
|
||||
#~ msgid "or"
|
||||
#~ msgstr ""
|
||||
|
|
|
|||
|
|
@ -88,6 +88,18 @@ for member_attrs <- [
|
|||
city: "Berlin",
|
||||
street: "Kastanienallee",
|
||||
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
|
||||
# Use upsert to prevent duplicates based on email
|
||||
|
|
|
|||
93
test/accounts/email_sync_edge_cases_test.exs
Normal file
93
test/accounts/email_sync_edge_cases_test.exs
Normal 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
|
||||
480
test/accounts/email_uniqueness_test.exs
Normal file
480
test/accounts/email_uniqueness_test.exs
Normal 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
|
||||
169
test/accounts/user_email_sync_test.exs
Normal file
169
test/accounts/user_email_sync_test.exs
Normal 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
|
||||
127
test/membership/member_email_sync_test.exs
Normal file
127
test/membership/member_email_sync_test.exs
Normal 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
|
||||
|
|
@ -18,14 +18,14 @@ defmodule MvWeb.Components.SearchBarComponentTest do
|
|||
html =
|
||||
view
|
||||
|> element("form[role=search]")
|
||||
|> render_change(%{"query" => "Friedrich"})
|
||||
|> render_submit(%{"query" => "Friedrich"})
|
||||
|
||||
refute html =~ "Greta"
|
||||
|
||||
html =
|
||||
view
|
||||
|> element("form[role=search]")
|
||||
|> render_change(%{"query" => "Greta"})
|
||||
|> render_submit(%{"query" => "Greta"})
|
||||
|
||||
refute html =~ "Friedrich"
|
||||
end
|
||||
|
|
|
|||
319
test/mv_web/components/sort_header_component_test.exs
Normal file
319
test/mv_web/components/sort_header_component_test.exs
Normal 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
|
||||
|
|
@ -56,7 +56,6 @@ defmodule MvWeb.MemberLive.IndexTest do
|
|||
|
||||
test "shows translated flash message after creating a member in English", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
conn = Plug.Test.init_test_session(conn, locale: "en")
|
||||
{:ok, form_view, _html} = live(conn, "/members/new")
|
||||
|
||||
form_data = %{
|
||||
|
|
@ -75,6 +74,143 @@ defmodule MvWeb.MemberLive.IndexTest do
|
|||
assert has_element?(index_view, "#flash-group", "Member create successfully")
|
||||
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
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, view, _html} = live(conn, "/members")
|
||||
|
|
|
|||
46
test/seeds_test.exs
Normal file
46
test/seeds_test.exs
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue