Compare commits
113 commits
docs/210_p
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 9cda832b82 | |||
| 613a5f2643 | |||
| 3d4020cf27 | |||
| e03693ada5 | |||
| f0391d3fef | |||
| 702eebd110 | |||
| 5ae4450444 | |||
| cf6a108049 | |||
| fabfe64468 | |||
| 6029920c3f | |||
| 6cf955b024 | |||
| 217ed632fa | |||
| 3b038d451d | |||
| ecc6522571 | |||
| b9bd5882e7 | |||
| 690083bdf0 | |||
| 4bbba65038 | |||
| 75e1fc8a3a | |||
| a68a15be6a | |||
| 8ce89a7227 | |||
| f5b67de870 | |||
| 188a6f667c | |||
| a483c287b6 | |||
| a12ca6b041 | |||
| ba5fc34d80 | |||
| a92771ffca | |||
| 80a06c3609 | |||
| a0ce88f71b | |||
| 5c8a44c388 | |||
| 5c1a766e87 | |||
|
|
6c935b7540 | ||
| 2542bcf9e4 | |||
| ed961f7585 | |||
| c17445975c | |||
| c9678231f9 | |||
| c3b33b55a5 | |||
| 8d1d04fa05 | |||
| 064c0df701 | |||
| 94245cbc0f | |||
| 82f1a65b85 | |||
| dfff2486b5 | |||
| aaea6a01e2 | |||
| 4057b2d631 | |||
| cd1af5aff5 | |||
| 8391122426 | |||
| a5aeef3e27 | |||
| 422cf37a1e | |||
| a10d42f1ed | |||
| d1bab1288c | |||
| 1623b63207 | |||
| e6c5a58c65 | |||
| ee414c9440 | |||
| 366d4c104a | |||
|
|
26a46d966a | ||
| 09c75212b2 | |||
| ce15b8f59b | |||
| f0613fe1e5 | |||
| d8384098b4 | |||
| 0cafdbafcd | |||
| ee094eec2f | |||
| 125f9ae77b | |||
| 206e733511 | |||
| a143c4e243 | |||
| b0c94234a9 | |||
| eedd24b93c | |||
| 06ba50f05d | |||
| 780f5f61ea | |||
| ac2ad0a0d5 | |||
| 875c422b7d | |||
| 6d75766dba | |||
| 354029c9cc | |||
| 671e6ce804 | |||
| 386b4c9e65 | |||
| 88c5f3dde0 | |||
| a67a91cffa | |||
| 0fb43a0816 | |||
| 45a9bc0cc0 | |||
| c8968636a8 | |||
| 40835f7a2d | |||
| 13f77b5c0a | |||
| dce2053ce7 | |||
| e81aecce48 | |||
| 397cbde9d6 | |||
| 831149f463 | |||
| 944b868478 | |||
| d10f2ecc90 | |||
| d757d1b9be | |||
| 39d2cb7820 | |||
| ba78a6ac7a | |||
| e2ace3d2a8 | |||
| d039e4bb7d | |||
| 7f0da693ee | |||
| 82e41916d2 | |||
| a022d8cd02 | |||
| f24d4985fc | |||
| cf957563bb | |||
| e803dbdf8b | |||
| f9ff6d3d2d | |||
| dfdf4c980b | |||
| cf354bcf25 | |||
| fdae610da0 | |||
| 37553d8d6c | |||
| 193618eace | |||
| 418b42d35a | |||
| a132383d81 | |||
| b584581114 | |||
| 2284cd93c4 | |||
| 82bd573276 | |||
| e7c4a4f62f | |||
| 100ed96493 | |||
| 11179e51f0 | |||
| 4313703538 | |||
| b509dc4ea3 |
90 changed files with 11343 additions and 1666 deletions
|
|
@ -53,6 +53,8 @@ steps:
|
|||
- mix hex.audit
|
||||
# Provide hints for improving code quality
|
||||
- mix credo
|
||||
# Check that translations are up to date
|
||||
- mix gettext.extract --check-up-to-date
|
||||
|
||||
- name: wait_for_postgres
|
||||
image: docker.io/library/postgres:17.6
|
||||
|
|
@ -164,7 +166,7 @@ environment:
|
|||
|
||||
steps:
|
||||
- name: renovate
|
||||
image: renovate/renovate:41.151
|
||||
image: renovate/renovate:41.173
|
||||
environment:
|
||||
RENOVATE_CONFIG_FILE: "renovate_backend_config.js"
|
||||
RENOVATE_TOKEN:
|
||||
|
|
|
|||
|
|
@ -8,6 +8,9 @@ TOKEN_SIGNING_SECRET=changeme-run-mix-phx.gen.secret
|
|||
# Required: Hostname for URL generation
|
||||
PHX_HOST=localhost
|
||||
|
||||
# Recommended: Association settings
|
||||
ASSOCIATION_NAME="Sportsclub XYZ"
|
||||
|
||||
# Optional: OIDC Configuration
|
||||
# These have defaults in docker-compose.prod.yml, only override if needed
|
||||
# OIDC_CLIENT_ID=mv
|
||||
|
|
|
|||
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -41,3 +41,6 @@ npm-debug.log
|
|||
.env
|
||||
|
||||
.elixir_ls/
|
||||
|
||||
# Docker secrets directory (generated by `just init-secrets`)
|
||||
/secrets/
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
elixir 1.18.3-otp-27
|
||||
erlang 27.3.4
|
||||
just 1.43.0
|
||||
just 1.43.1
|
||||
|
|
|
|||
|
|
@ -12,8 +12,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||
- PostgreSQL trigram-based member search with typo tolerance
|
||||
- WCAG 2.1 AA compliant autocomplete dropdown with ARIA support
|
||||
- Bilingual UI (German/English) for member linking workflow
|
||||
- **Bulk email copy feature** - Copy email addresses of selected members to clipboard (#230)
|
||||
- Email format: "First Last <email>" with semicolon separator (compatible with email clients)
|
||||
- CopyToClipboard JavaScript hook with fallback for older browsers
|
||||
- Button shows count of visible selected members (respects search/filter)
|
||||
- German/English translations
|
||||
- Docker secrets support via `_FILE` environment variables for all sensitive configuration (SECRET_KEY_BASE, TOKEN_SIGNING_SECRET, OIDC_CLIENT_SECRET, DATABASE_URL, DATABASE_PASSWORD)
|
||||
|
||||
### Fixed
|
||||
- Email validation false positive when linking user and member with identical emails (#168 Problem #4)
|
||||
- Relationship data extraction from Ash manage_relationship during validation
|
||||
- Copy button count now shows only visible selected members when filtering
|
||||
|
||||
|
|
|
|||
35
Justfile
35
Justfile
|
|
@ -1,4 +1,7 @@
|
|||
set dotenv-load := true
|
||||
set export := true
|
||||
|
||||
MIX_QUIET := "1"
|
||||
|
||||
run: install-dependencies start-database migrate-database seed-database
|
||||
mix phx.server
|
||||
|
|
@ -29,6 +32,7 @@ lint:
|
|||
mix format --check-formatted
|
||||
mix compile --warnings-as-errors
|
||||
mix credo
|
||||
mix gettext.extract --check-up-to-date
|
||||
|
||||
audit:
|
||||
mix sobelow --config
|
||||
|
|
@ -83,4 +87,33 @@ regen-migrations migration_name commit_hash='':
|
|||
clean:
|
||||
mix clean
|
||||
rm -rf .elixir_ls
|
||||
rm -rf _build
|
||||
rm -rf _build
|
||||
|
||||
# Remove Git merge conflict markers from gettext files
|
||||
remove-gettext-conflicts:
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
find priv/gettext -type f -exec sed -i '/^<<<<<<</d; /^=======$/d; /^>>>>>>>/d; /^%%%%%%%/d; /^++++++/d; s/^+//; s/^-//' {} \;
|
||||
|
||||
# Production environment commands
|
||||
# ================================
|
||||
|
||||
# Initialize secrets directory with generated secrets (only if not exists)
|
||||
init-prod-secrets:
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
if [ -d "secrets" ]; then
|
||||
echo "Secrets directory already exists. Skipping generation."
|
||||
exit 0
|
||||
fi
|
||||
echo "Creating secrets directory and generating secrets..."
|
||||
mkdir -p secrets
|
||||
mix phx.gen.secret > secrets/secret_key_base.txt
|
||||
mix phx.gen.secret > secrets/token_signing_secret.txt
|
||||
openssl rand -base64 32 | tr -d '\n' > secrets/db_password.txt
|
||||
touch secrets/oidc_client_secret.txt
|
||||
echo "Secrets generated in ./secrets/"
|
||||
|
||||
# Start production environment with Docker Compose
|
||||
start-prod: init-prod-secrets
|
||||
docker compose -f docker-compose.prod.yml up -d
|
||||
40
README.md
40
README.md
|
|
@ -45,7 +45,7 @@ Our philosophy: **software should help people spend less time on administration
|
|||
- 🚧 Sorting & filtering
|
||||
- 🚧 Roles & permissions (e.g. board, treasurer)
|
||||
- ✅ Custom fields (flexible per club needs)
|
||||
- ✅ SSO via OIDC (tested with Rauthy)
|
||||
- ✅ SSO via OIDC (works with Authentik, Rauthy, Keycloak, etc.)
|
||||
- 🚧 Self-service & online application
|
||||
- 🚧 Accessibility, GDPR, usability improvements
|
||||
- 🚧 Email sending
|
||||
|
|
@ -147,7 +147,26 @@ Mila uses OIDC for Single Sign-On. In development, a local **Rauthy** instance i
|
|||
5. copy client secret to `.env` file
|
||||
6. abort and run `just run` again
|
||||
|
||||
Now you can log in to Mila via OIDC!
|
||||
Now you can log in to Mila via OIDC!
|
||||
|
||||
### OIDC with other providers (Authentik, Keycloak, etc.)
|
||||
|
||||
Mila works with any OIDC-compliant provider. The internal strategy is named `:rauthy`, but this is just a name — it works with any provider.
|
||||
|
||||
**Important:** The redirect URI must always end with `/auth/user/rauthy/callback`.
|
||||
|
||||
Example for Authentik:
|
||||
1. Create an OAuth2/OpenID Provider in Authentik
|
||||
2. Set the redirect URI to: `https://your-domain.com/auth/user/rauthy/callback`
|
||||
3. Configure environment variables:
|
||||
```bash
|
||||
DOMAIN=your-domain.com # or PHX_HOST=your-domain.com
|
||||
OIDC_CLIENT_ID=your-client-id
|
||||
OIDC_BASE_URL=https://auth.example.com/application/o/your-app
|
||||
OIDC_CLIENT_SECRET=your-client-secret # or use OIDC_CLIENT_SECRET_FILE
|
||||
```
|
||||
|
||||
The `OIDC_REDIRECT_URI` is auto-generated as `https://{DOMAIN}/auth/user/rauthy/callback` if not explicitly set.
|
||||
|
||||
## ⚙️ Configuration
|
||||
|
||||
|
|
@ -210,13 +229,20 @@ For testing the production Docker build locally:
|
|||
# Required variables:
|
||||
SECRET_KEY_BASE=<your-generated-secret>
|
||||
TOKEN_SIGNING_SECRET=<your-generated-secret>
|
||||
PHX_HOST=localhost
|
||||
DOMAIN=localhost # or PHX_HOST=localhost
|
||||
|
||||
# Optional (have defaults in docker-compose.prod.yml):
|
||||
# Optional OIDC configuration:
|
||||
# OIDC_CLIENT_ID=mv
|
||||
# OIDC_BASE_URL=http://localhost:8080/auth/v1
|
||||
# OIDC_REDIRECT_URI=http://localhost:4001/auth/user/rauthy/callback
|
||||
# OIDC_CLIENT_SECRET=<from-rauthy-client>
|
||||
# OIDC_CLIENT_SECRET=<from-your-oidc-provider>
|
||||
# OIDC_REDIRECT_URI is auto-generated as https://{DOMAIN}/auth/user/rauthy/callback
|
||||
|
||||
# Alternative: Use _FILE variables for Docker secrets (takes priority over regular vars):
|
||||
# SECRET_KEY_BASE_FILE=/run/secrets/secret_key_base
|
||||
# TOKEN_SIGNING_SECRET_FILE=/run/secrets/token_signing_secret
|
||||
# OIDC_CLIENT_SECRET_FILE=/run/secrets/oidc_client_secret
|
||||
# DATABASE_URL_FILE=/run/secrets/database_url
|
||||
# DATABASE_PASSWORD_FILE=/run/secrets/database_password
|
||||
```
|
||||
|
||||
3. **Start development environment** (for Rauthy):
|
||||
|
|
@ -250,7 +276,7 @@ For actual production deployment:
|
|||
- Set `OIDC_BASE_URL` to your production OIDC provider
|
||||
- Configure proper Docker networks
|
||||
3. **Set up SSL/TLS** (e.g., via reverse proxy like Nginx/Traefik)
|
||||
4. **Use secure secrets management** (environment variables, Docker secrets, vault)
|
||||
4. **Use secure secrets management** — All sensitive environment variables support a `_FILE` suffix for Docker secrets (e.g., `SECRET_KEY_BASE_FILE=/run/secrets/secret_key_base`). See `docker-compose.prod.yml` for an example setup with Docker secrets.
|
||||
5. **Configure database backups**
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -27,6 +27,33 @@ let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("
|
|||
// Hooks for LiveView components
|
||||
let Hooks = {}
|
||||
|
||||
// CopyToClipboard hook: Copies text to clipboard when triggered by server event
|
||||
Hooks.CopyToClipboard = {
|
||||
mounted() {
|
||||
this.handleEvent("copy_to_clipboard", ({text}) => {
|
||||
if (navigator.clipboard) {
|
||||
navigator.clipboard.writeText(text).catch(err => {
|
||||
console.error("Clipboard write failed:", err)
|
||||
})
|
||||
} else {
|
||||
// Fallback for older browsers
|
||||
const textArea = document.createElement("textarea")
|
||||
textArea.value = text
|
||||
textArea.style.position = "fixed"
|
||||
textArea.style.left = "-999999px"
|
||||
document.body.appendChild(textArea)
|
||||
textArea.select()
|
||||
try {
|
||||
document.execCommand("copy")
|
||||
} catch (err) {
|
||||
console.error("Fallback clipboard copy failed:", err)
|
||||
}
|
||||
document.body.removeChild(textArea)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ComboBox hook: Prevents form submission when Enter is pressed in dropdown
|
||||
Hooks.ComboBox = {
|
||||
mounted() {
|
||||
|
|
|
|||
|
|
@ -7,6 +7,75 @@ import Config
|
|||
# any compile-time configuration in here, as it won't be applied.
|
||||
# The block below contains prod specific runtime configuration.
|
||||
|
||||
# Helper function to read environment variables with Docker secrets support.
|
||||
# Supports the _FILE suffix pattern: if VAR_FILE is set, reads the value from
|
||||
# that file path. Otherwise falls back to VAR directly.
|
||||
# VAR_FILE takes priority and must contain the full absolute path to the secret file.
|
||||
get_env_or_file = fn var_name, default ->
|
||||
file_var = "#{var_name}_FILE"
|
||||
|
||||
case System.get_env(file_var) do
|
||||
nil ->
|
||||
System.get_env(var_name, default)
|
||||
|
||||
file_path ->
|
||||
case File.read(file_path) do
|
||||
{:ok, content} ->
|
||||
String.trim_trailing(content)
|
||||
|
||||
{:error, reason} ->
|
||||
raise """
|
||||
Failed to read secret from file specified in #{file_var}="#{file_path}".
|
||||
Error: #{inspect(reason)}
|
||||
"""
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Same as get_env_or_file but raises if the value is not set
|
||||
get_env_or_file! = fn var_name, error_message ->
|
||||
case get_env_or_file.(var_name, nil) do
|
||||
nil -> raise error_message
|
||||
value -> value
|
||||
end
|
||||
end
|
||||
|
||||
# Build database URL from individual components or use DATABASE_URL directly.
|
||||
# Supports both approaches:
|
||||
# 1. DATABASE_URL (or DATABASE_URL_FILE) - full connection URL
|
||||
# 2. Separate vars: DATABASE_HOST, DATABASE_USER, DATABASE_PASSWORD (or _FILE), DATABASE_NAME, DATABASE_PORT
|
||||
build_database_url = fn ->
|
||||
case get_env_or_file.("DATABASE_URL", nil) do
|
||||
nil ->
|
||||
# Build URL from separate components
|
||||
host =
|
||||
System.get_env("DATABASE_HOST") ||
|
||||
raise "DATABASE_HOST is required when DATABASE_URL is not set"
|
||||
|
||||
user =
|
||||
System.get_env("DATABASE_USER") ||
|
||||
raise "DATABASE_USER is required when DATABASE_URL is not set"
|
||||
|
||||
password =
|
||||
get_env_or_file!.("DATABASE_PASSWORD", """
|
||||
DATABASE_PASSWORD or DATABASE_PASSWORD_FILE is required when DATABASE_URL is not set.
|
||||
""")
|
||||
|
||||
database =
|
||||
System.get_env("DATABASE_NAME") ||
|
||||
raise "DATABASE_NAME is required when DATABASE_URL is not set"
|
||||
|
||||
port = System.get_env("DATABASE_PORT", "5432")
|
||||
|
||||
# URL-encode the password to handle special characters
|
||||
encoded_password = URI.encode_www_form(password)
|
||||
"ecto://#{user}:#{encoded_password}@#{host}:#{port}/#{database}"
|
||||
|
||||
url ->
|
||||
url
|
||||
end
|
||||
end
|
||||
|
||||
# ## Using releases
|
||||
#
|
||||
# If you use `mix release`, you need to explicitly enable the server
|
||||
|
|
@ -21,12 +90,7 @@ if System.get_env("PHX_SERVER") do
|
|||
end
|
||||
|
||||
if config_env() == :prod do
|
||||
database_url =
|
||||
System.get_env("DATABASE_URL") ||
|
||||
raise """
|
||||
environment variable DATABASE_URL is missing.
|
||||
For example: ecto://USER:PASS@HOST/DATABASE
|
||||
"""
|
||||
database_url = build_database_url.()
|
||||
|
||||
maybe_ipv6 = if System.get_env("ECTO_IPV6") in ~w(true 1), do: [:inet6], else: []
|
||||
|
||||
|
|
@ -41,45 +105,72 @@ if config_env() == :prod do
|
|||
# want to use a different value for prod and you most likely don't want
|
||||
# to check this value into version control, so we use an environment
|
||||
# variable instead.
|
||||
# Supports SECRET_KEY_BASE or SECRET_KEY_BASE_FILE for Docker secrets.
|
||||
secret_key_base =
|
||||
System.get_env("SECRET_KEY_BASE") ||
|
||||
raise """
|
||||
environment variable SECRET_KEY_BASE is missing.
|
||||
You can generate one by calling: mix phx.gen.secret
|
||||
"""
|
||||
get_env_or_file!.("SECRET_KEY_BASE", """
|
||||
environment variable SECRET_KEY_BASE (or SECRET_KEY_BASE_FILE) is missing.
|
||||
You can generate one by calling: mix phx.gen.secret
|
||||
""")
|
||||
|
||||
# PHX_HOST or DOMAIN can be used to set the host for the application.
|
||||
# DOMAIN is commonly used in deployment environments (e.g., Portainer templates).
|
||||
host =
|
||||
System.get_env("PHX_HOST") ||
|
||||
System.get_env("DOMAIN") ||
|
||||
raise "Please define the PHX_HOST or DOMAIN environment variable."
|
||||
|
||||
host = System.get_env("PHX_HOST") || raise "Please define the PHX_HOST environment variable."
|
||||
port = String.to_integer(System.get_env("PORT") || "4000")
|
||||
|
||||
config :mv, :dns_cluster_query, System.get_env("DNS_CLUSTER_QUERY")
|
||||
|
||||
# Rauthy OIDC configuration
|
||||
# OIDC configuration (works with any OIDC provider: Authentik, Rauthy, Keycloak, etc.)
|
||||
# Note: The strategy is named :rauthy internally, but works with any OIDC provider.
|
||||
# The redirect_uri callback path is always /auth/user/rauthy/callback regardless of provider.
|
||||
#
|
||||
# Supports OIDC_CLIENT_SECRET or OIDC_CLIENT_SECRET_FILE for Docker secrets.
|
||||
# OIDC_CLIENT_SECRET is required only if OIDC is being used (indicated by explicit OIDC env vars).
|
||||
oidc_base_url = System.get_env("OIDC_BASE_URL")
|
||||
oidc_client_id = System.get_env("OIDC_CLIENT_ID")
|
||||
oidc_in_use = not is_nil(oidc_base_url) or not is_nil(oidc_client_id)
|
||||
|
||||
client_secret =
|
||||
if oidc_in_use do
|
||||
get_env_or_file!.("OIDC_CLIENT_SECRET", """
|
||||
environment variable OIDC_CLIENT_SECRET (or OIDC_CLIENT_SECRET_FILE) is missing.
|
||||
This is required when OIDC authentication is configured (OIDC_BASE_URL or OIDC_CLIENT_ID is set).
|
||||
""")
|
||||
else
|
||||
get_env_or_file.("OIDC_CLIENT_SECRET", nil)
|
||||
end
|
||||
|
||||
# Build redirect_uri: use OIDC_REDIRECT_URI if set, otherwise build from host.
|
||||
# Uses HTTPS since production runs behind TLS termination.
|
||||
default_redirect_uri = "https://#{host}/auth/user/rauthy/callback"
|
||||
|
||||
config :mv, :rauthy,
|
||||
client_id: System.get_env("OIDC_CLIENT_ID") || "mv",
|
||||
base_url: System.get_env("OIDC_BASE_URL") || "http://localhost:8080/auth/v1",
|
||||
client_secret: System.get_env("OIDC_CLIENT_SECRET"),
|
||||
redirect_uri:
|
||||
System.get_env("OIDC_REDIRECT_URI") || "http://#{host}:#{port}/auth/user/rauthy/callback"
|
||||
client_id: oidc_client_id || "mv",
|
||||
base_url: oidc_base_url || "http://localhost:8080/auth/v1",
|
||||
client_secret: client_secret,
|
||||
redirect_uri: System.get_env("OIDC_REDIRECT_URI") || default_redirect_uri
|
||||
|
||||
# Token signing secret from environment variable
|
||||
# This overrides the placeholder value set in prod.exs
|
||||
# Supports TOKEN_SIGNING_SECRET or TOKEN_SIGNING_SECRET_FILE for Docker secrets.
|
||||
token_signing_secret =
|
||||
System.get_env("TOKEN_SIGNING_SECRET") ||
|
||||
raise """
|
||||
environment variable TOKEN_SIGNING_SECRET is missing.
|
||||
You can generate one by calling: mix phx.gen.secret
|
||||
"""
|
||||
get_env_or_file!.("TOKEN_SIGNING_SECRET", """
|
||||
environment variable TOKEN_SIGNING_SECRET (or TOKEN_SIGNING_SECRET_FILE) is missing.
|
||||
You can generate one by calling: mix phx.gen.secret
|
||||
""")
|
||||
|
||||
config :mv, :token_signing_secret, token_signing_secret
|
||||
|
||||
config :mv, MvWeb.Endpoint,
|
||||
url: [host: host, port: 443, scheme: "https"],
|
||||
http: [
|
||||
# Enable IPv6 and bind on all interfaces.
|
||||
# Set it to {0, 0, 0, 0, 0, 0, 0, 1} for local network only access.
|
||||
# Bind on all IPv4 interfaces.
|
||||
# Use {0, 0, 0, 0, 0, 0, 0, 0} for IPv6, or {127, 0, 0, 1} for localhost only.
|
||||
# See the documentation on https://hexdocs.pm/bandit/Bandit.html#t:options/0
|
||||
# for details about using IPv6 vs IPv4 and loopback vs public addresses.
|
||||
ip: {0, 0, 0, 0, 0, 0, 0, 0},
|
||||
ip: {0, 0, 0, 0},
|
||||
port: port
|
||||
],
|
||||
secret_key_base: secret_key_base,
|
||||
|
|
|
|||
|
|
@ -45,3 +45,6 @@ config :mv, :token_signing_secret, "test_secret_key_for_ash_authentication_token
|
|||
config :mv, :session_identifier, :unsafe
|
||||
|
||||
config :mv, :require_token_presence_for_authentication, false
|
||||
|
||||
# Enable SQL Sandbox for async LiveView tests
|
||||
config :mv, :sql_sandbox, true
|
||||
|
|
|
|||
|
|
@ -2,21 +2,32 @@ services:
|
|||
app:
|
||||
image: git.local-it.org/local-it/mitgliederverwaltung:latest
|
||||
container_name: mv-prod-app
|
||||
# Use host network for local testing to access localhost:8080 (Rauthy)
|
||||
# In real production, remove this and use external OIDC provider
|
||||
network_mode: host
|
||||
ports:
|
||||
- "4001:4001"
|
||||
environment:
|
||||
DATABASE_URL: "ecto://postgres:postgres@localhost:5001/mv_prod"
|
||||
SECRET_KEY_BASE: "${SECRET_KEY_BASE}"
|
||||
TOKEN_SIGNING_SECRET: "${TOKEN_SIGNING_SECRET}"
|
||||
PHX_HOST: "${PHX_HOST}"
|
||||
# Database configuration using separate variables
|
||||
# Use Docker service name for internal networking
|
||||
DATABASE_HOST: "db-prod"
|
||||
DATABASE_PORT: "5432"
|
||||
DATABASE_USER: "postgres"
|
||||
DATABASE_NAME: "mv_prod"
|
||||
DATABASE_PASSWORD_FILE: "/run/secrets/db_password"
|
||||
# Phoenix secrets via Docker secrets
|
||||
SECRET_KEY_BASE_FILE: "/run/secrets/secret_key_base"
|
||||
TOKEN_SIGNING_SECRET_FILE: "/run/secrets/token_signing_secret"
|
||||
PHX_HOST: "${PHX_HOST:-localhost}"
|
||||
PORT: "4001"
|
||||
PHX_SERVER: "true"
|
||||
# Rauthy OIDC config - uses localhost because of host network mode
|
||||
# Rauthy OIDC config - use host.docker.internal to reach host services
|
||||
OIDC_CLIENT_ID: "mv"
|
||||
OIDC_BASE_URL: "http://localhost:8080/auth/v1"
|
||||
OIDC_CLIENT_SECRET: "${OIDC_CLIENT_SECRET:-}"
|
||||
OIDC_BASE_URL: "http://host.docker.internal:8080/auth/v1"
|
||||
OIDC_CLIENT_SECRET_FILE: "/run/secrets/oidc_client_secret"
|
||||
OIDC_REDIRECT_URI: "http://localhost:4001/auth/user/rauthy/callback"
|
||||
secrets:
|
||||
- db_password
|
||||
- secret_key_base
|
||||
- token_signing_secret
|
||||
- oidc_client_secret
|
||||
depends_on:
|
||||
- db-prod
|
||||
restart: unless-stopped
|
||||
|
|
@ -26,13 +37,25 @@ services:
|
|||
container_name: mv-prod-db
|
||||
environment:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
POSTGRES_PASSWORD_FILE: /run/secrets/db_password
|
||||
POSTGRES_DB: mv_prod
|
||||
secrets:
|
||||
- db_password
|
||||
volumes:
|
||||
- postgres_data_prod:/var/lib/postgresql/data
|
||||
ports:
|
||||
- "5001:5432"
|
||||
restart: unless-stopped
|
||||
|
||||
secrets:
|
||||
db_password:
|
||||
file: ./secrets/db_password.txt
|
||||
secret_key_base:
|
||||
file: ./secrets/secret_key_base.txt
|
||||
token_signing_secret:
|
||||
file: ./secrets/token_signing_secret.txt
|
||||
oidc_client_secret:
|
||||
file: ./secrets/oidc_client_secret.txt
|
||||
|
||||
volumes:
|
||||
postgres_data_prod:
|
||||
|
|
|
|||
|
|
@ -115,7 +115,6 @@ Member (1) → (N) Properties
|
|||
### Member Constraints
|
||||
- First name and last name required (min 1 char)
|
||||
- Email unique, validated format (5-254 chars)
|
||||
- Birth date cannot be in future
|
||||
- Join date cannot be in future
|
||||
- Exit date must be after join date
|
||||
- Phone: `+?[0-9\- ]{6,20}`
|
||||
|
|
@ -169,7 +168,7 @@ Member (1) → (N) Properties
|
|||
### Weighted Fields
|
||||
- **Weight A (highest):** first_name, last_name
|
||||
- **Weight B:** email, notes
|
||||
- **Weight C:** birth_date, phone_number, city, street, house_number, postal_code
|
||||
- **Weight C:** phone_number, city, street, house_number, postal_code
|
||||
- **Weight D (lowest):** join_date, exit_date
|
||||
|
||||
### Usage Example
|
||||
|
|
@ -381,7 +380,7 @@ Install "DBML Language" extension to view/edit DBML files with:
|
|||
- tokens (jti, purpose, extra_data)
|
||||
|
||||
**Personal Data (GDPR):**
|
||||
- All member fields (name, email, birth_date, address)
|
||||
- All member fields (name, email, address)
|
||||
- User email
|
||||
- Token subject
|
||||
|
||||
|
|
|
|||
|
|
@ -122,7 +122,6 @@ Table members {
|
|||
first_name text [not null, note: 'Member first name (min length: 1)']
|
||||
last_name text [not null, note: 'Member last name (min length: 1)']
|
||||
email text [not null, unique, note: 'Member email address (5-254 chars, validated)']
|
||||
birth_date date [null, note: 'Date of birth (cannot be in future)']
|
||||
paid boolean [null, note: 'Payment status flag']
|
||||
phone_number text [null, note: 'Contact phone number (format: +?[0-9\- ]{6,20})']
|
||||
join_date date [null, note: 'Date when member joined club (cannot be in future)']
|
||||
|
|
@ -153,7 +152,7 @@ Table members {
|
|||
**Club Member Master Data**
|
||||
|
||||
Core entity for membership management containing:
|
||||
- Personal information (name, birth date, email)
|
||||
- Personal information (name, email)
|
||||
- Contact details (phone, address)
|
||||
- Membership status (join/exit dates, payment status)
|
||||
- Additional notes
|
||||
|
|
@ -183,7 +182,6 @@ Table members {
|
|||
**Validation Rules:**
|
||||
- first_name, last_name: min 1 character
|
||||
- email: 5-254 characters, valid email format
|
||||
- birth_date: cannot be in future
|
||||
- join_date: cannot be in future
|
||||
- exit_date: must be after join_date (if both present)
|
||||
- phone_number: matches pattern ^\+?[0-9\- ]{6,20}$
|
||||
|
|
|
|||
|
|
@ -329,6 +329,11 @@ end
|
|||
|
||||
---
|
||||
|
||||
**PR #208:** *Show custom fields per default in member overview* 🔧
|
||||
- added show_in_overview as attribute to custom fields
|
||||
- show custom fields in member overview per default
|
||||
- can be set to false in the settings for the specific custom field
|
||||
|
||||
## Implementation Decisions
|
||||
|
||||
### Architecture Patterns
|
||||
|
|
@ -390,6 +395,7 @@ defmodule Mv.Membership.CustomField do
|
|||
attribute :value_type, :atom # :string, :integer, :boolean, :date, :email
|
||||
attribute :immutable, :boolean # Can't change after creation
|
||||
attribute :required, :boolean # All members must have this
|
||||
attribute :show_in_overview, :boolean # "If true, this custom field will be displayed in the member overview table"
|
||||
end
|
||||
|
||||
# CustomFieldValue stores values
|
||||
|
|
@ -1321,6 +1327,33 @@ end
|
|||
|
||||
---
|
||||
|
||||
## Session: Bulk Email Copy Feature (2025-12-02)
|
||||
|
||||
### Feature Summary
|
||||
Implemented bulk email copy functionality for selected members (#230). Users can select members and copy their email addresses to clipboard.
|
||||
|
||||
**Key Features:**
|
||||
- Copy button appears only when visible members are selected
|
||||
- Email format: `First Last <email>` with semicolon separator (email client compatible)
|
||||
- Button shows count of visible selected members (respects search/filter)
|
||||
- CopyToClipboard JavaScript hook with clipboard API + fallback for older browsers
|
||||
- Bilingual UI (English/German)
|
||||
|
||||
### Key Decisions
|
||||
|
||||
1. **Email Format:** "First Last <email>" with semicolon - standard for all major email clients
|
||||
2. **Visible Member Count:** Button shows only visible selected members, not total selected (better UX when filtering)
|
||||
3. **Server→Client:** Used `push_event/3` - server formats data, client handles clipboard
|
||||
|
||||
### Files Changed
|
||||
- `lib/mv_web/live/member_live/index.ex` - Event handler, helper function
|
||||
- `lib/mv_web/live/member_live/index.html.heex` - Copy button
|
||||
- `assets/js/app.js` - CopyToClipboard hook
|
||||
- `test/mv_web/member_live/index_test.exs` - 9 new tests
|
||||
- `priv/gettext/de/LC_MESSAGES/default.po` - German translations
|
||||
|
||||
---
|
||||
|
||||
## Session: User-Member Linking UI Enhancement (2025-01-13)
|
||||
|
||||
### Feature Summary
|
||||
|
|
@ -1553,8 +1586,8 @@ This project demonstrates a modern Phoenix application built with:
|
|||
|
||||
---
|
||||
|
||||
**Document Version:** 1.2
|
||||
**Last Updated:** 2025-11-27
|
||||
**Document Version:** 1.3
|
||||
**Last Updated:** 2025-12-02
|
||||
**Maintainer:** Development Team
|
||||
**Status:** Living Document (update as project evolves)
|
||||
|
||||
|
|
|
|||
|
|
@ -65,6 +65,7 @@
|
|||
- ✅ Sorting by basic fields
|
||||
- ✅ User-Member linking (optional 1:1)
|
||||
- ✅ Email synchronization between User and Member
|
||||
- ✅ **Bulk email copy** - Copy selected members' email addresses to clipboard (Issue #230)
|
||||
|
||||
**Closed Issues:**
|
||||
- ✅ [#162](https://git.local-it.org/local-it/mitgliederverwaltung/issues/162) - Fuzzy and substring search (closed 2025-11-12)
|
||||
|
|
@ -94,15 +95,18 @@
|
|||
- ✅ CustomFieldValue type management
|
||||
- ✅ Dynamic custom field value assignment to members
|
||||
- ✅ Union type storage (JSONB)
|
||||
- ✅ Default field visibility configuration
|
||||
|
||||
**Closed Issues:**
|
||||
- [#194](https://git.local-it.org/local-it/mitgliederverwaltung/issues/194) - Custom Fields: Harden implementation (S)
|
||||
- [#197](https://git.local-it.org/local-it/mitgliederverwaltung/issues/197) - Custom Fields: Add option to show custom fields in member overview (M)
|
||||
- [#161](https://git.local-it.org/local-it/mitgliederverwaltung/issues/161) - Remove birthday field from default configuration (S) - Closed 2025-12-02
|
||||
|
||||
**Open Issues:**
|
||||
- [#194](https://git.local-it.org/local-it/mitgliederverwaltung/issues/194) - Custom Fields: Harden implementation (S) [0/3 tasks]
|
||||
- [#157](https://git.local-it.org/local-it/mitgliederverwaltung/issues/157) - Concept how custom fields are handled (M, High priority) [0/4 tasks]
|
||||
- [#161](https://git.local-it.org/local-it/mitgliederverwaltung/issues/161) - Don't show birthday field for default configurations (S, Low priority)
|
||||
- [#153](https://git.local-it.org/local-it/mitgliederverwaltung/issues/153) - Sorting functionalities for custom fields (M, Low priority)
|
||||
|
||||
**Missing Features:**
|
||||
- ❌ Default field visibility configuration
|
||||
- ❌ Field groups/categories
|
||||
- ❌ Conditional fields (show field X if field Y = value)
|
||||
- ❌ Field validation rules (min/max, regex patterns)
|
||||
|
|
@ -183,10 +187,16 @@
|
|||
|
||||
**Current State:**
|
||||
- ✅ Basic "paid" boolean field on members
|
||||
- ✅ **UI Mock-ups for Contribution Types & Settings** (2025-12-02)
|
||||
- ⚠️ No payment tracking
|
||||
|
||||
**Open Issues:**
|
||||
- [#156](https://git.local-it.org/local-it/mitgliederverwaltung/issues/156) - Set up & document testing environment for vereinfacht.digital (L, Low priority)
|
||||
- [#226](https://git.local-it.org/local-it/mitgliederverwaltung/issues/226) - Payment/Contribution Mockup Pages (Preview)
|
||||
|
||||
**Mock-Up Pages (Non-Functional Preview):**
|
||||
- `/contribution_types` - Contribution Types Management
|
||||
- `/contribution_settings` - Global Contribution Settings
|
||||
|
||||
**Missing Features:**
|
||||
- ❌ Membership fee configuration
|
||||
|
|
|
|||
|
|
@ -54,6 +54,9 @@ defmodule Mv.Accounts.User do
|
|||
auth_method :client_secret_jwt
|
||||
code_verifier true
|
||||
|
||||
# Request email and profile scopes from OIDC provider (required for Authentik, Keycloak, etc.)
|
||||
authorization_params scope: "openid email profile"
|
||||
|
||||
# id_token_signed_response_alg "EdDSA" #-> https://git.local-it.org/local-it/mitgliederverwaltung/issues/87
|
||||
end
|
||||
|
||||
|
|
@ -69,7 +72,7 @@ defmodule Mv.Accounts.User do
|
|||
# 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.
|
||||
#
|
||||
#
|
||||
# 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:
|
||||
|
|
@ -185,7 +188,9 @@ defmodule Mv.Accounts.User do
|
|||
oidc_user_info = Ash.Changeset.get_argument(changeset, :oidc_user_info)
|
||||
|
||||
# Get the new email from OIDC user_info
|
||||
new_email = Map.get(oidc_user_info, "preferred_username")
|
||||
# Support both "email" (standard OIDC) and "preferred_username" (Rauthy)
|
||||
new_email =
|
||||
Map.get(oidc_user_info, "email") || Map.get(oidc_user_info, "preferred_username")
|
||||
|
||||
changeset
|
||||
|> Ash.Changeset.change_attribute(:oidc_id, oidc_id)
|
||||
|
|
@ -239,8 +244,11 @@ defmodule Mv.Accounts.User do
|
|||
change fn changeset, _ctx ->
|
||||
user_info = Ash.Changeset.get_argument(changeset, :user_info)
|
||||
|
||||
# Support both "email" (standard OIDC like Authentik, Keycloak) and "preferred_username" (Rauthy)
|
||||
email = user_info["email"] || user_info["preferred_username"]
|
||||
|
||||
changeset
|
||||
|> Ash.Changeset.change_attribute(:email, user_info["preferred_username"])
|
||||
|> Ash.Changeset.change_attribute(:email, email)
|
||||
|> Ash.Changeset.change_attribute(:oidc_id, user_info["sub"] || user_info["id"])
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ defmodule Mv.Membership.CustomField do
|
|||
- `description` - Optional human-readable description
|
||||
- `immutable` - If true, custom field values cannot be changed after creation
|
||||
- `required` - If true, all members must have this custom field (future feature)
|
||||
- `show_in_overview` - If true, this custom field will be displayed in the member overview table and can be sorted
|
||||
|
||||
## Supported Value Types
|
||||
- `:string` - Text data (max 10,000 characters)
|
||||
|
|
@ -59,10 +60,10 @@ defmodule Mv.Membership.CustomField do
|
|||
|
||||
actions do
|
||||
defaults [:read, :update]
|
||||
default_accept [:name, :value_type, :description, :immutable, :required]
|
||||
default_accept [:name, :value_type, :description, :immutable, :required, :show_in_overview]
|
||||
|
||||
create :create do
|
||||
accept [:name, :value_type, :description, :immutable, :required]
|
||||
accept [:name, :value_type, :description, :immutable, :required, :show_in_overview]
|
||||
change Mv.Membership.CustomField.Changes.GenerateSlug
|
||||
validate string_length(:slug, min: 1)
|
||||
end
|
||||
|
|
@ -119,6 +120,12 @@ defmodule Mv.Membership.CustomField do
|
|||
attribute :required, :boolean,
|
||||
default: false,
|
||||
allow_nil?: false
|
||||
|
||||
attribute :show_in_overview, :boolean,
|
||||
default: true,
|
||||
allow_nil?: false,
|
||||
public?: true,
|
||||
description: "If true, this custom field will be displayed in the member overview table"
|
||||
end
|
||||
|
||||
relationships do
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ defmodule Mv.Membership.Member do
|
|||
- Email format validation (using EctoCommons.EmailValidator)
|
||||
- Phone number format: international format with 6-20 digits
|
||||
- Postal code format: exactly 5 digits (German format)
|
||||
- Date validations: birth_date and join_date not in future, exit_date after join_date
|
||||
- Date validations: join_date not in future, exit_date after join_date
|
||||
- Email uniqueness: prevents conflicts with unlinked users
|
||||
|
||||
## Full-Text Search
|
||||
|
|
@ -42,6 +42,10 @@ defmodule Mv.Membership.Member do
|
|||
@member_search_limit 10
|
||||
@default_similarity_threshold 0.2
|
||||
|
||||
# Use constants from Mv.Constants for member fields
|
||||
# This ensures consistency across the codebase
|
||||
@member_fields Mv.Constants.member_fields()
|
||||
|
||||
postgres do
|
||||
table "members"
|
||||
repo Mv.Repo
|
||||
|
|
@ -58,21 +62,7 @@ defmodule Mv.Membership.Member do
|
|||
# user_id is NOT in accept list to prevent direct foreign key manipulation
|
||||
argument :user, :map, allow_nil?: true
|
||||
|
||||
accept [
|
||||
:first_name,
|
||||
:last_name,
|
||||
:email,
|
||||
:birth_date,
|
||||
:paid,
|
||||
:phone_number,
|
||||
:join_date,
|
||||
:exit_date,
|
||||
:notes,
|
||||
:city,
|
||||
:street,
|
||||
:house_number,
|
||||
:postal_code
|
||||
]
|
||||
accept @member_fields
|
||||
|
||||
change manage_relationship(:custom_field_values, type: :create)
|
||||
|
||||
|
|
@ -105,21 +95,7 @@ defmodule Mv.Membership.Member do
|
|||
# user_id is NOT in accept list to prevent direct foreign key manipulation
|
||||
argument :user, :map, allow_nil?: true
|
||||
|
||||
accept [
|
||||
:first_name,
|
||||
:last_name,
|
||||
:email,
|
||||
:birth_date,
|
||||
:paid,
|
||||
:phone_number,
|
||||
:join_date,
|
||||
:exit_date,
|
||||
:notes,
|
||||
:city,
|
||||
:street,
|
||||
:house_number,
|
||||
:postal_code
|
||||
]
|
||||
accept @member_fields
|
||||
|
||||
change manage_relationship(:custom_field_values, on_match: :update, on_no_match: :create)
|
||||
|
||||
|
|
@ -308,11 +284,6 @@ defmodule Mv.Membership.Member do
|
|||
end
|
||||
end
|
||||
|
||||
# Birth date not in the future
|
||||
validate compare(:birth_date, less_than_or_equal_to: &Date.utc_today/0),
|
||||
where: [present(:birth_date)],
|
||||
message: "cannot be in the future"
|
||||
|
||||
# Join date not in the future
|
||||
validate compare(:join_date, less_than_or_equal_to: &Date.utc_today/0),
|
||||
where: [present(:join_date)],
|
||||
|
|
@ -375,10 +346,6 @@ defmodule Mv.Membership.Member do
|
|||
constraints min_length: 5, max_length: 254
|
||||
end
|
||||
|
||||
attribute :birth_date, :date do
|
||||
allow_nil? true
|
||||
end
|
||||
|
||||
attribute :paid, :boolean do
|
||||
allow_nil? true
|
||||
end
|
||||
|
|
@ -434,6 +401,70 @@ defmodule Mv.Membership.Member do
|
|||
identity :unique_email, [:email]
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if a member field should be shown in the overview.
|
||||
|
||||
Reads the visibility configuration from Settings resource. If a field is not
|
||||
configured in settings, it defaults to `true` (visible).
|
||||
|
||||
## Parameters
|
||||
- `field` - Atom representing the member field name (e.g., `:email`, `:street`)
|
||||
|
||||
## Returns
|
||||
- `true` if the field should be shown in overview (default)
|
||||
- `false` if the field is configured as hidden in settings
|
||||
|
||||
## Examples
|
||||
|
||||
iex> Member.show_in_overview?(:email)
|
||||
true
|
||||
|
||||
iex> Member.show_in_overview?(:street)
|
||||
true # or false if configured in settings
|
||||
|
||||
"""
|
||||
@spec show_in_overview?(atom()) :: boolean()
|
||||
def show_in_overview?(field) when is_atom(field) do
|
||||
case Mv.Membership.get_settings() do
|
||||
{:ok, settings} ->
|
||||
visibility_config = settings.member_field_visibility || %{}
|
||||
# Normalize map keys to atoms (JSONB may return string keys)
|
||||
normalized_config = normalize_visibility_config(visibility_config)
|
||||
|
||||
# Get value from normalized config, default to true
|
||||
Map.get(normalized_config, field, true)
|
||||
|
||||
{:error, _} ->
|
||||
# If settings can't be loaded, default to visible
|
||||
true
|
||||
end
|
||||
end
|
||||
|
||||
def show_in_overview?(_), do: true
|
||||
|
||||
# Normalizes visibility config map keys from strings to atoms.
|
||||
# JSONB in PostgreSQL converts atom keys to string keys when storing.
|
||||
defp normalize_visibility_config(config) when is_map(config) do
|
||||
Enum.reduce(config, %{}, fn
|
||||
{key, value}, acc when is_atom(key) ->
|
||||
Map.put(acc, key, value)
|
||||
|
||||
{key, value}, acc when is_binary(key) ->
|
||||
try do
|
||||
atom_key = String.to_existing_atom(key)
|
||||
Map.put(acc, atom_key, value)
|
||||
rescue
|
||||
ArgumentError ->
|
||||
acc
|
||||
end
|
||||
|
||||
_, acc ->
|
||||
acc
|
||||
end)
|
||||
end
|
||||
|
||||
defp normalize_visibility_config(_), do: %{}
|
||||
|
||||
@doc """
|
||||
Performs fuzzy search on members using PostgreSQL trigram similarity.
|
||||
|
||||
|
|
|
|||
|
|
@ -6,12 +6,14 @@ defmodule Mv.Membership do
|
|||
- `Member` - Club members with personal information and custom field values
|
||||
- `CustomFieldValue` - Dynamic custom field values attached to members
|
||||
- `CustomField` - Schema definitions for custom fields
|
||||
- `Setting` - Global application settings (singleton)
|
||||
|
||||
## Public API
|
||||
The domain exposes these main actions:
|
||||
- Member CRUD: `create_member/1`, `list_members/0`, `update_member/2`, `destroy_member/1`
|
||||
- Custom field value management: `create_custom_field_value/1`, `list_custom_field_values/0`, etc.
|
||||
- Custom field management: `create_custom_field/1`, `list_custom_fields/0`, etc.
|
||||
- Settings management: `get_settings/0`, `update_settings/2`
|
||||
|
||||
## Admin Interface
|
||||
The domain is configured with AshAdmin for management UI.
|
||||
|
|
@ -45,5 +47,114 @@ defmodule Mv.Membership do
|
|||
define :destroy_custom_field, action: :destroy_with_values
|
||||
define :prepare_custom_field_deletion, action: :prepare_deletion, args: [:id]
|
||||
end
|
||||
|
||||
resource Mv.Membership.Setting do
|
||||
# Note: create action exists but is not exposed via code interface
|
||||
# It's only used internally as fallback in get_settings/0
|
||||
# Settings should be created via seed script
|
||||
define :update_settings, action: :update
|
||||
define :update_member_field_visibility, action: :update_member_field_visibility
|
||||
end
|
||||
end
|
||||
|
||||
# Singleton pattern: Get the single settings record
|
||||
@doc """
|
||||
Gets the global settings.
|
||||
|
||||
Settings should normally be created via the seed script (`priv/repo/seeds.exs`).
|
||||
If no settings exist, this function will create them as a fallback using the
|
||||
`ASSOCIATION_NAME` environment variable or "Club Name" as default.
|
||||
|
||||
## Returns
|
||||
|
||||
- `{:ok, settings}` - The settings record
|
||||
- `{:ok, nil}` - No settings exist (should not happen if seeds were run)
|
||||
- `{:error, error}` - Error reading settings
|
||||
|
||||
## Examples
|
||||
|
||||
iex> {:ok, settings} = Mv.Membership.get_settings()
|
||||
iex> settings.club_name
|
||||
"My Club"
|
||||
|
||||
"""
|
||||
def get_settings do
|
||||
# Try to get the first (and only) settings record
|
||||
case Ash.read_one(Mv.Membership.Setting, domain: __MODULE__) do
|
||||
{:ok, nil} ->
|
||||
# No settings exist - create as fallback (should normally be created via seed script)
|
||||
default_club_name = System.get_env("ASSOCIATION_NAME") || "Club Name"
|
||||
|
||||
Mv.Membership.Setting
|
||||
|> Ash.Changeset.for_create(:create, %{club_name: default_club_name})
|
||||
|> Ash.create!(domain: __MODULE__)
|
||||
|> then(fn settings -> {:ok, settings} end)
|
||||
|
||||
{:ok, settings} ->
|
||||
{:ok, settings}
|
||||
|
||||
{:error, error} ->
|
||||
{:error, error}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Updates the global settings.
|
||||
|
||||
## Parameters
|
||||
|
||||
- `settings` - The settings record to update
|
||||
- `attrs` - A map of attributes to update (e.g., `%{club_name: "New Name"}`)
|
||||
|
||||
## Returns
|
||||
|
||||
- `{:ok, updated_settings}` - Successfully updated settings
|
||||
- `{:error, error}` - Validation or update error
|
||||
|
||||
## Examples
|
||||
|
||||
iex> {:ok, settings} = Mv.Membership.get_settings()
|
||||
iex> {:ok, updated} = Mv.Membership.update_settings(settings, %{club_name: "New Club"})
|
||||
iex> updated.club_name
|
||||
"New Club"
|
||||
|
||||
"""
|
||||
def update_settings(settings, attrs) do
|
||||
settings
|
||||
|> Ash.Changeset.for_update(:update, attrs)
|
||||
|> Ash.update(domain: __MODULE__)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Updates the member field visibility configuration.
|
||||
|
||||
This is a specialized action for updating only the member field visibility settings.
|
||||
It validates that all keys are valid member fields and all values are booleans.
|
||||
|
||||
## Parameters
|
||||
|
||||
- `settings` - The settings record to update
|
||||
- `visibility_config` - A map of member field names (strings) to boolean visibility values
|
||||
(e.g., `%{"street" => false, "house_number" => false}`)
|
||||
|
||||
## Returns
|
||||
|
||||
- `{:ok, updated_settings}` - Successfully updated settings
|
||||
- `{:error, error}` - Validation or update error
|
||||
|
||||
## Examples
|
||||
|
||||
iex> {:ok, settings} = Mv.Membership.get_settings()
|
||||
iex> {:ok, updated} = Mv.Membership.update_member_field_visibility(settings, %{"street" => false, "house_number" => false})
|
||||
iex> updated.member_field_visibility
|
||||
%{"street" => false, "house_number" => false}
|
||||
|
||||
"""
|
||||
def update_member_field_visibility(settings, visibility_config) do
|
||||
settings
|
||||
|> Ash.Changeset.for_update(:update_member_field_visibility, %{
|
||||
member_field_visibility: visibility_config
|
||||
})
|
||||
|> Ash.update(domain: __MODULE__)
|
||||
end
|
||||
end
|
||||
|
|
|
|||
138
lib/membership/setting.ex
Normal file
138
lib/membership/setting.ex
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
defmodule Mv.Membership.Setting do
|
||||
@moduledoc """
|
||||
Ash resource representing global application settings.
|
||||
|
||||
## Overview
|
||||
Settings is a singleton resource that stores global configuration for the association,
|
||||
such as the club name and branding information. There should only ever be one settings
|
||||
record in the database.
|
||||
|
||||
## Attributes
|
||||
- `club_name` - The name of the association/club (required, cannot be empty)
|
||||
- `member_field_visibility` - JSONB map storing visibility configuration for member fields
|
||||
(e.g., `%{"street" => false, "house_number" => false}`). Fields not in the map default to `true`.
|
||||
|
||||
## Singleton Pattern
|
||||
This resource uses a singleton pattern - there should only be one settings record.
|
||||
The resource is designed to be read and updated, but not created or destroyed
|
||||
through normal CRUD operations. Initial settings should be seeded.
|
||||
|
||||
## Environment Variable Support
|
||||
The `club_name` can be set via the `ASSOCIATION_NAME` environment variable.
|
||||
If set, the environment variable value is used as a fallback when no database
|
||||
value exists. Database values always take precedence over environment variables.
|
||||
|
||||
## Examples
|
||||
|
||||
# Get current settings
|
||||
{:ok, settings} = Mv.Membership.get_settings()
|
||||
settings.club_name # => "My Club"
|
||||
|
||||
# Update club name
|
||||
{:ok, updated} = Mv.Membership.update_settings(settings, %{club_name: "New Name"})
|
||||
|
||||
# Update member field visibility
|
||||
{:ok, updated} = Mv.Membership.update_member_field_visibility(settings, %{"street" => false, "house_number" => false})
|
||||
"""
|
||||
use Ash.Resource,
|
||||
domain: Mv.Membership,
|
||||
data_layer: AshPostgres.DataLayer
|
||||
|
||||
postgres do
|
||||
table "settings"
|
||||
repo Mv.Repo
|
||||
end
|
||||
|
||||
resource do
|
||||
description "Global application settings (singleton resource)"
|
||||
end
|
||||
|
||||
actions do
|
||||
defaults [:read]
|
||||
|
||||
# Internal create action - not exposed via code interface
|
||||
# Used only as fallback in get_settings/0 if settings don't exist
|
||||
# Settings should normally be created via seed script
|
||||
create :create do
|
||||
accept [:club_name, :member_field_visibility]
|
||||
end
|
||||
|
||||
update :update do
|
||||
primary? true
|
||||
require_atomic? false
|
||||
accept [:club_name, :member_field_visibility]
|
||||
end
|
||||
|
||||
update :update_member_field_visibility do
|
||||
description "Updates the visibility configuration for member fields in the overview"
|
||||
require_atomic? false
|
||||
accept [:member_field_visibility]
|
||||
end
|
||||
end
|
||||
|
||||
validations do
|
||||
validate present(:club_name), on: [:create, :update]
|
||||
validate string_length(:club_name, min: 1), on: [:create, :update]
|
||||
|
||||
# Validate member_field_visibility map structure and content
|
||||
validate fn changeset, _context ->
|
||||
visibility = Ash.Changeset.get_attribute(changeset, :member_field_visibility)
|
||||
|
||||
if visibility && is_map(visibility) do
|
||||
# Validate all values are booleans
|
||||
invalid_values =
|
||||
Enum.filter(visibility, fn {_key, value} ->
|
||||
not is_boolean(value)
|
||||
end)
|
||||
|
||||
# Validate all keys are valid member fields
|
||||
valid_field_strings = Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1)
|
||||
|
||||
invalid_keys =
|
||||
Enum.filter(visibility, fn {key, _value} ->
|
||||
key not in valid_field_strings
|
||||
end)
|
||||
|> Enum.map(fn {key, _value} -> key end)
|
||||
|
||||
cond do
|
||||
not Enum.empty?(invalid_values) ->
|
||||
{:error,
|
||||
field: :member_field_visibility,
|
||||
message: "All values in member_field_visibility must be booleans"}
|
||||
|
||||
not Enum.empty?(invalid_keys) ->
|
||||
{:error,
|
||||
field: :member_field_visibility,
|
||||
message: "Invalid member field keys: #{inspect(invalid_keys)}"}
|
||||
|
||||
true ->
|
||||
:ok
|
||||
end
|
||||
else
|
||||
:ok
|
||||
end
|
||||
end,
|
||||
on: [:create, :update]
|
||||
end
|
||||
|
||||
attributes do
|
||||
uuid_primary_key :id
|
||||
|
||||
attribute :club_name, :string,
|
||||
allow_nil?: false,
|
||||
public?: true,
|
||||
description: "The name of the association/club",
|
||||
constraints: [
|
||||
trim?: true,
|
||||
min_length: 1
|
||||
]
|
||||
|
||||
attribute :member_field_visibility, :map,
|
||||
allow_nil?: true,
|
||||
public?: true,
|
||||
description:
|
||||
"Configuration for member field visibility in overview (JSONB map). Keys are member field names (atoms), values are booleans."
|
||||
|
||||
timestamps()
|
||||
end
|
||||
end
|
||||
34
lib/mv/constants.ex
Normal file
34
lib/mv/constants.ex
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
defmodule Mv.Constants do
|
||||
@moduledoc """
|
||||
Module for defining constants and atoms.
|
||||
"""
|
||||
|
||||
@member_fields [
|
||||
:first_name,
|
||||
:last_name,
|
||||
:email,
|
||||
:paid,
|
||||
:phone_number,
|
||||
:join_date,
|
||||
:exit_date,
|
||||
:notes,
|
||||
:city,
|
||||
:street,
|
||||
:house_number,
|
||||
:postal_code
|
||||
]
|
||||
|
||||
@custom_field_prefix "custom_field_"
|
||||
|
||||
def member_fields, do: @member_fields
|
||||
|
||||
@doc """
|
||||
Returns the prefix used for custom field keys in field visibility maps.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> Mv.Constants.custom_field_prefix()
|
||||
"custom_field_"
|
||||
"""
|
||||
def custom_field_prefix, do: @custom_field_prefix
|
||||
end
|
||||
|
|
@ -42,7 +42,11 @@ defmodule MvWeb.CoreComponents do
|
|||
attr :id, :string, doc: "the optional id of flash container"
|
||||
attr :flash, :map, default: %{}, doc: "the map of flash messages to display"
|
||||
attr :title, :string, default: nil
|
||||
attr :kind, :atom, values: [:info, :error], doc: "used for styling and flash lookup"
|
||||
|
||||
attr :kind, :atom,
|
||||
values: [:info, :error, :success, :warning],
|
||||
doc: "used for styling and flash lookup"
|
||||
|
||||
attr :rest, :global, doc: "the arbitrary HTML attributes to add to the flash container"
|
||||
|
||||
slot :inner_block, doc: "the optional inner block that renders the flash message"
|
||||
|
|
@ -56,22 +60,26 @@ defmodule MvWeb.CoreComponents do
|
|||
id={@id}
|
||||
phx-click={JS.push("lv:clear-flash", value: %{key: @kind}) |> hide("##{@id}")}
|
||||
role="alert"
|
||||
class="toast toast-top toast-end z-50"
|
||||
class="z-50 toast toast-top toast-end"
|
||||
{@rest}
|
||||
>
|
||||
<div class={[
|
||||
"alert w-80 sm:w-96 max-w-80 sm:max-w-96 text-wrap",
|
||||
@kind == :info && "alert-info",
|
||||
@kind == :error && "alert-error"
|
||||
@kind == :error && "alert-error",
|
||||
@kind == :success && "bg-green-500 text-white",
|
||||
@kind == :warning && "bg-blue-100 text-blue-800 border border-blue-300"
|
||||
]}>
|
||||
<.icon :if={@kind == :info} name="hero-information-circle" class="size-5 shrink-0" />
|
||||
<.icon :if={@kind == :error} name="hero-exclamation-circle" class="size-5 shrink-0" />
|
||||
<.icon :if={@kind == :success} name="hero-check-circle" class="size-5 shrink-0" />
|
||||
<.icon :if={@kind == :warning} name="hero-information-circle" class="size-5 shrink-0" />
|
||||
<div>
|
||||
<p :if={@title} class="font-semibold">{@title}</p>
|
||||
<p>{msg}</p>
|
||||
</div>
|
||||
<div class="flex-1" />
|
||||
<button type="button" class="group self-start cursor-pointer" aria-label={gettext("close")}>
|
||||
<button type="button" class="self-start cursor-pointer group" aria-label={gettext("close")}>
|
||||
<.icon name="hero-x-mark" class="size-5 opacity-40 group-hover:opacity-70" />
|
||||
</button>
|
||||
</div>
|
||||
|
|
@ -111,6 +119,123 @@ defmodule MvWeb.CoreComponents do
|
|||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders a dropdown menu.
|
||||
|
||||
## Examples
|
||||
|
||||
<.dropdown_menu items={@items} open={@open} phx_target={@myself} />
|
||||
"""
|
||||
attr :id, :string, default: "dropdown-menu"
|
||||
attr :items, :list, required: true, doc: "List of %{label: string, value: any} maps"
|
||||
attr :button_label, :string, default: "Dropdown"
|
||||
attr :icon, :string, default: nil
|
||||
attr :checkboxes, :boolean, default: false
|
||||
attr :selected, :map, default: %{}
|
||||
attr :open, :boolean, default: false, doc: "Whether the dropdown is open"
|
||||
attr :show_select_buttons, :boolean, default: false, doc: "Show select all/none buttons"
|
||||
attr :phx_target, :any, required: true, doc: "The LiveView/LiveComponent target for events"
|
||||
|
||||
def dropdown_menu(assigns) do
|
||||
~H"""
|
||||
<div
|
||||
class="relative"
|
||||
phx-click-away="close_dropdown"
|
||||
phx-target={@phx_target}
|
||||
phx-window-keydown="close_dropdown"
|
||||
phx-key="Escape"
|
||||
data-testid="dropdown-menu"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
tabindex="0"
|
||||
role="button"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={@open}
|
||||
aria-controls={@id}
|
||||
class="btn btn-ghost"
|
||||
phx-click="toggle_dropdown"
|
||||
phx-target={@phx_target}
|
||||
data-testid="dropdown-button"
|
||||
>
|
||||
<%= if @icon do %>
|
||||
<.icon name={@icon} />
|
||||
<% end %>
|
||||
<span>{@button_label}</span>
|
||||
</button>
|
||||
|
||||
<ul
|
||||
:if={@open}
|
||||
id={@id}
|
||||
role="menu"
|
||||
class="absolute right-0 mt-2 bg-base-100 z-[100] p-2 shadow-lg rounded-box w-64 max-h-96 overflow-y-auto border border-base-300"
|
||||
tabindex="0"
|
||||
phx-target={@phx_target}
|
||||
>
|
||||
<li :if={@show_select_buttons} role="none">
|
||||
<div class="flex justify-between items-center mb-2 px-2">
|
||||
<span class="font-semibold">{gettext("Options")}</span>
|
||||
<div class="flex gap-1">
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
aria-label={gettext("Select all")}
|
||||
phx-click="select_all"
|
||||
phx-target={@phx_target}
|
||||
class="btn btn-xs btn-ghost"
|
||||
>
|
||||
{gettext("All")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
aria-label={gettext("Select none")}
|
||||
phx-click="select_none"
|
||||
phx-target={@phx_target}
|
||||
class="btn btn-xs btn-ghost"
|
||||
>
|
||||
{gettext("None")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
<li :if={@show_select_buttons} role="separator" class="divider my-1"></li>
|
||||
|
||||
<%= for item <- @items do %>
|
||||
<li role="none">
|
||||
<button
|
||||
type="button"
|
||||
role={if @checkboxes, do: "menuitemcheckbox", else: "menuitem"}
|
||||
aria-checked={
|
||||
if @checkboxes, do: to_string(Map.get(@selected, item.value, true)), else: nil
|
||||
}
|
||||
tabindex="0"
|
||||
class="flex items-center gap-2 px-2 py-1 rounded cursor-pointer hover:bg-base-200 w-full text-left"
|
||||
phx-click="select_item"
|
||||
phx-keydown="select_item"
|
||||
phx-key="Enter"
|
||||
phx-value-item={item.value}
|
||||
phx-target={@phx_target}
|
||||
>
|
||||
<%= if @checkboxes do %>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={Map.get(@selected, item.value, true)}
|
||||
class="checkbox checkbox-sm checkbox-primary"
|
||||
tabindex="-1"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<% end %>
|
||||
<span>{item.label}</span>
|
||||
</button>
|
||||
</li>
|
||||
<% end %>
|
||||
</ul>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders an input with label and error messages.
|
||||
|
||||
|
|
@ -180,7 +305,7 @@ defmodule MvWeb.CoreComponents do
|
|||
end)
|
||||
|
||||
~H"""
|
||||
<fieldset class="fieldset mb-2">
|
||||
<fieldset class="mb-2 fieldset">
|
||||
<label>
|
||||
<input type="hidden" name={@name} value="false" disabled={@rest[:disabled]} />
|
||||
<span class="label">
|
||||
|
|
@ -192,7 +317,11 @@ defmodule MvWeb.CoreComponents do
|
|||
checked={@checked}
|
||||
class={@class || "checkbox checkbox-sm"}
|
||||
{@rest}
|
||||
/>{@label}
|
||||
/>{@label}<span
|
||||
:if={@rest[:required]}
|
||||
class="text-red-700 tooltip tooltip-right"
|
||||
data-tip={gettext("This field cannot be empty")}
|
||||
>*</span>
|
||||
</span>
|
||||
</label>
|
||||
<.error :for={msg <- @errors}>{msg}</.error>
|
||||
|
|
@ -202,9 +331,15 @@ defmodule MvWeb.CoreComponents do
|
|||
|
||||
def input(%{type: "select"} = assigns) do
|
||||
~H"""
|
||||
<fieldset class="fieldset mb-2">
|
||||
<fieldset class="mb-2 fieldset">
|
||||
<label>
|
||||
<span :if={@label} class="label mb-1">{@label}</span>
|
||||
<span :if={@label} class="mb-1 label">
|
||||
{@label}<span
|
||||
:if={@rest[:required]}
|
||||
class="text-red-700 tooltip tooltip-right"
|
||||
data-tip={gettext("This field cannot be empty")}
|
||||
>*</span>
|
||||
</span>
|
||||
<select
|
||||
id={@id}
|
||||
name={@name}
|
||||
|
|
@ -223,9 +358,15 @@ defmodule MvWeb.CoreComponents do
|
|||
|
||||
def input(%{type: "textarea"} = assigns) do
|
||||
~H"""
|
||||
<fieldset class="fieldset mb-2">
|
||||
<fieldset class="mb-2 fieldset">
|
||||
<label>
|
||||
<span :if={@label} class="label mb-1">{@label}</span>
|
||||
<span :if={@label} class="mb-1 label">
|
||||
{@label}<span
|
||||
:if={@rest[:required]}
|
||||
class="text-red-700 tooltip tooltip-right"
|
||||
data-tip={gettext("This field cannot be empty")}
|
||||
>*</span>
|
||||
</span>
|
||||
<textarea
|
||||
id={@id}
|
||||
name={@name}
|
||||
|
|
@ -244,9 +385,15 @@ defmodule MvWeb.CoreComponents do
|
|||
# All other inputs text, datetime-local, url, password, etc. are handled here...
|
||||
def input(assigns) do
|
||||
~H"""
|
||||
<fieldset class="fieldset mb-2">
|
||||
<fieldset class="mb-2 fieldset">
|
||||
<label>
|
||||
<span :if={@label} class="label mb-1">{@label}</span>
|
||||
<span :if={@label} class="mb-1 label">
|
||||
{@label}<span
|
||||
:if={@rest[:required]}
|
||||
class="text-red-700 tooltip tooltip-right"
|
||||
data-tip={gettext("This field cannot be empty")}
|
||||
>*</span>
|
||||
</span>
|
||||
<input
|
||||
type={@type}
|
||||
name={@name}
|
||||
|
|
@ -318,6 +465,13 @@ defmodule MvWeb.CoreComponents do
|
|||
default: &Function.identity/1,
|
||||
doc: "the function for mapping each row before calling the :col and :action slots"
|
||||
|
||||
attr :dynamic_cols, :list,
|
||||
default: [],
|
||||
doc: "list of dynamic column definitions with :custom_field and :render functions"
|
||||
|
||||
attr :sort_field, :any, default: nil, doc: "current sort field"
|
||||
attr :sort_order, :atom, default: nil, doc: "current sort order"
|
||||
|
||||
slot :col, required: true do
|
||||
attr :label, :string
|
||||
end
|
||||
|
|
@ -331,34 +485,63 @@ defmodule MvWeb.CoreComponents do
|
|||
end
|
||||
|
||||
~H"""
|
||||
<table class="table table-zebra">
|
||||
<thead>
|
||||
<tr>
|
||||
<th :for={col <- @col}>{col[:label]}</th>
|
||||
<th :if={@action != []}>
|
||||
<span class="sr-only">{gettext("Actions")}</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id={@id} phx-update={is_struct(@rows, Phoenix.LiveView.LiveStream) && "stream"}>
|
||||
<tr :for={row <- @rows} id={@row_id && @row_id.(row)}>
|
||||
<td
|
||||
:for={col <- @col}
|
||||
phx-click={@row_click && @row_click.(row)}
|
||||
class={@row_click && "hover:cursor-pointer"}
|
||||
>
|
||||
{render_slot(col, @row_item.(row))}
|
||||
</td>
|
||||
<td :if={@action != []} class="w-0 font-semibold">
|
||||
<div class="flex gap-4">
|
||||
<%= for action <- @action do %>
|
||||
{render_slot(action, @row_item.(row))}
|
||||
<% end %>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div class="overflow-auto">
|
||||
<table class="table table-zebra">
|
||||
<thead>
|
||||
<tr>
|
||||
<th :for={col <- @col}>{col[:label]}</th>
|
||||
<th :for={dyn_col <- @dynamic_cols}>
|
||||
<.live_component
|
||||
module={MvWeb.Components.SortHeaderComponent}
|
||||
id={:"sort_custom_field_#{dyn_col[:custom_field].id}"}
|
||||
field={"custom_field_#{dyn_col[:custom_field].id}"}
|
||||
label={dyn_col[:custom_field].name}
|
||||
sort_field={@sort_field}
|
||||
sort_order={@sort_order}
|
||||
/>
|
||||
</th>
|
||||
<th :if={@action != []}>
|
||||
<span class="sr-only">{gettext("Actions")}</span>
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id={@id} phx-update={is_struct(@rows, Phoenix.LiveView.LiveStream) && "stream"}>
|
||||
<tr :for={row <- @rows} id={@row_id && @row_id.(row)}>
|
||||
<td
|
||||
:for={col <- @col}
|
||||
phx-click={@row_click && @row_click.(row)}
|
||||
class={["max-w-xs truncate", @row_click && "hover:cursor-pointer"]}
|
||||
>
|
||||
{render_slot(col, @row_item.(row))}
|
||||
</td>
|
||||
<td
|
||||
:for={dyn_col <- @dynamic_cols}
|
||||
phx-click={@row_click && @row_click.(row)}
|
||||
class={["max-w-xs truncate", @row_click && "hover:cursor-pointer"]}
|
||||
>
|
||||
{if dyn_col[:render] do
|
||||
rendered = dyn_col[:render].(@row_item.(row))
|
||||
|
||||
if rendered == "" do
|
||||
""
|
||||
else
|
||||
rendered
|
||||
end
|
||||
else
|
||||
""
|
||||
end}
|
||||
</td>
|
||||
<td :if={@action != []} class="w-0 font-semibold">
|
||||
<div class="flex gap-4">
|
||||
<%= for action <- @action do %>
|
||||
{render_slot(action, @row_item.(row))}
|
||||
<% end %>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
|
|
@ -483,7 +666,7 @@ defmodule MvWeb.CoreComponents do
|
|||
<div class="mt-14">
|
||||
<dl class="-my-4 divide-y divide-zinc-100">
|
||||
<div :for={{name, value} <- @items} class="flex gap-4 py-4 text-sm leading-6 sm:gap-8">
|
||||
<dt class="w-1/4 flex-none text-zinc-500">{name}</dt>
|
||||
<dt class="flex-none w-1/4 text-zinc-500">{name}</dt>
|
||||
<dd class="text-zinc-700">{value}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
|
|
|||
|
|
@ -65,7 +65,9 @@ defmodule MvWeb.Layouts do
|
|||
|
||||
def flash_group(assigns) do
|
||||
~H"""
|
||||
<div id={@id} aria-live="polite">
|
||||
<div id={@id} aria-live="polite" class="toast toast-top toast-end z-50 flex flex-col gap-2">
|
||||
<.flash kind={:success} flash={@flash} />
|
||||
<.flash kind={:warning} flash={@flash} />
|
||||
<.flash kind={:info} flash={@flash} />
|
||||
<.flash kind={:error} flash={@flash} />
|
||||
|
||||
|
|
|
|||
|
|
@ -6,19 +6,36 @@ defmodule MvWeb.Layouts.Navbar do
|
|||
use Gettext, backend: MvWeb.Gettext
|
||||
use MvWeb, :verified_routes
|
||||
|
||||
alias Mv.Membership
|
||||
|
||||
attr :current_user, :map,
|
||||
required: true,
|
||||
doc: "The current user - navbar is only shown when user is present"
|
||||
|
||||
def navbar(assigns) do
|
||||
club_name = get_club_name()
|
||||
|
||||
assigns = assign(assigns, :club_name, club_name)
|
||||
|
||||
~H"""
|
||||
<header class="navbar bg-base-100 shadow-sm">
|
||||
<div class="flex-1">
|
||||
<a class="btn btn-ghost text-xl">Mitgliederverwaltung</a>
|
||||
<a class="btn btn-ghost text-xl">{@club_name}</a>
|
||||
<ul class="menu menu-horizontal bg-base-200">
|
||||
<li><.link navigate="/members">{gettext("Members")}</.link></li>
|
||||
<li><.link navigate="/custom_fields">{gettext("Custom Fields")}</.link></li>
|
||||
<li><.link navigate="/settings">{gettext("Settings")}</.link></li>
|
||||
<li><.link navigate="/users">{gettext("Users")}</.link></li>
|
||||
<li>
|
||||
<details>
|
||||
<summary>{gettext("Contributions")}</summary>
|
||||
<ul class="bg-base-200 rounded-t-none p-2 z-10 w-48">
|
||||
<li><.link navigate="/contribution_types">{gettext("Contribution Types")}</.link></li>
|
||||
<li>
|
||||
<.link navigate="/contribution_settings">{gettext("Contribution Settings")}</.link>
|
||||
</li>
|
||||
</ul>
|
||||
</details>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
|
|
@ -89,7 +106,9 @@ defmodule MvWeb.Layouts.Navbar do
|
|||
{gettext("Profil")}
|
||||
</.link>
|
||||
</li>
|
||||
<li><a>{gettext("Settings")}</a></li>
|
||||
<li>
|
||||
<.link navigate={~p"/settings"}>{gettext("Settings")}</.link>
|
||||
</li>
|
||||
<li>
|
||||
<.link href={~p"/sign-out"}>{gettext("Logout")}</.link>
|
||||
</li>
|
||||
|
|
@ -99,4 +118,13 @@ defmodule MvWeb.Layouts.Navbar do
|
|||
</header>
|
||||
"""
|
||||
end
|
||||
|
||||
# Helper function to get club name from settings
|
||||
# Falls back to "Mitgliederverwaltung" if settings can't be loaded
|
||||
defp get_club_name do
|
||||
case Membership.get_settings() do
|
||||
{:ok, settings} -> settings.club_name
|
||||
_ -> "Mitgliederverwaltung"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -39,6 +39,11 @@ defmodule MvWeb.Endpoint do
|
|||
plug Phoenix.Ecto.CheckRepoStatus, otp_app: :mv
|
||||
end
|
||||
|
||||
# Enable Ecto SQL Sandbox in test environment for async tests
|
||||
if Application.compile_env(:mv, :sql_sandbox) do
|
||||
plug Phoenix.Ecto.SQL.Sandbox
|
||||
end
|
||||
|
||||
plug Phoenix.LiveDashboard.RequestLogger,
|
||||
param_key: "request_logger",
|
||||
cookie_key: "request_logger"
|
||||
|
|
|
|||
27
lib/mv_web/helpers/date_formatter.ex
Normal file
27
lib/mv_web/helpers/date_formatter.ex
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
defmodule MvWeb.Helpers.DateFormatter do
|
||||
@moduledoc """
|
||||
Centralized date formatting helper for the application.
|
||||
Formats dates in European format (dd.mm.yyyy).
|
||||
"""
|
||||
|
||||
use Gettext, backend: MvWeb.Gettext
|
||||
|
||||
@doc """
|
||||
Formats a Date struct to European format (dd.mm.yyyy).
|
||||
|
||||
## Examples
|
||||
|
||||
iex> MvWeb.Helpers.DateFormatter.format_date(~D[2024-03-15])
|
||||
"15.03.2024"
|
||||
|
||||
iex> MvWeb.Helpers.DateFormatter.format_date(nil)
|
||||
""
|
||||
"""
|
||||
def format_date(%Date{} = date) do
|
||||
Calendar.strftime(date, "%d.%m.%Y")
|
||||
end
|
||||
|
||||
def format_date(nil), do: ""
|
||||
|
||||
def format_date(_), do: "Invalid date"
|
||||
end
|
||||
|
|
@ -0,0 +1,176 @@
|
|||
defmodule MvWeb.Components.FieldVisibilityDropdownComponent do
|
||||
@moduledoc """
|
||||
LiveComponent for managing field visibility in the member overview.
|
||||
|
||||
Provides an accessible dropdown menu where users can select/deselect
|
||||
which member fields and custom fields are visible in the table.
|
||||
|
||||
## Props
|
||||
- `:all_fields` - List of all available fields
|
||||
- `:custom_fields` - List of CustomField resources
|
||||
- `:selected_fields` - Map field_name → boolean
|
||||
- `:id` - Component ID
|
||||
|
||||
## Events sent to parent:
|
||||
- `{:field_toggled, field, value}`
|
||||
- `{:fields_selected, map}`
|
||||
"""
|
||||
|
||||
use MvWeb, :live_component
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# UPDATE
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@impl true
|
||||
def update(assigns, socket) do
|
||||
socket =
|
||||
socket
|
||||
|> assign(assigns)
|
||||
|> assign_new(:open, fn -> false end)
|
||||
|> assign_new(:all_fields, fn -> [] end)
|
||||
|> assign_new(:custom_fields, fn -> [] end)
|
||||
|> assign_new(:selected_fields, fn -> %{} end)
|
||||
|
||||
{:ok, socket}
|
||||
end
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# RENDER
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
all_fields = assigns.all_fields || []
|
||||
custom_fields = assigns.custom_fields || []
|
||||
|
||||
all_items =
|
||||
Enum.map(extract_member_field_keys(all_fields), fn field ->
|
||||
%{
|
||||
value: field_to_string(field),
|
||||
label: format_field_label(field)
|
||||
}
|
||||
end) ++
|
||||
Enum.map(extract_custom_field_keys(all_fields), fn field ->
|
||||
%{
|
||||
value: field,
|
||||
label: format_custom_field_label(field, custom_fields)
|
||||
}
|
||||
end)
|
||||
|
||||
assigns = assign(assigns, :all_items, all_items)
|
||||
|
||||
# LiveComponents require a static HTML element as root, not a function component
|
||||
~H"""
|
||||
<div>
|
||||
<.dropdown_menu
|
||||
id="field-visibility-menu"
|
||||
icon="hero-adjustments-horizontal"
|
||||
button_label={gettext("Columns")}
|
||||
items={@all_items}
|
||||
checkboxes={true}
|
||||
selected={@selected_fields}
|
||||
open={@open}
|
||||
show_select_buttons={true}
|
||||
phx_target={@myself}
|
||||
/>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# EVENTS (matching the Core Component API)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@impl true
|
||||
def handle_event("toggle_dropdown", _params, socket) do
|
||||
{:noreply, assign(socket, :open, !socket.assigns.open)}
|
||||
end
|
||||
|
||||
def handle_event("close_dropdown", _params, socket) do
|
||||
{:noreply, assign(socket, :open, false)}
|
||||
end
|
||||
|
||||
# toggle single item
|
||||
def handle_event("select_item", %{"item" => item}, socket) do
|
||||
current = Map.get(socket.assigns.selected_fields, item, true)
|
||||
updated = Map.put(socket.assigns.selected_fields, item, !current)
|
||||
|
||||
send(self(), {:field_toggled, item, !current})
|
||||
{:noreply, assign(socket, :selected_fields, updated)}
|
||||
end
|
||||
|
||||
# select all
|
||||
def handle_event("select_all", _params, socket) do
|
||||
all =
|
||||
socket.assigns.all_fields
|
||||
|> Enum.map(&field_to_string/1)
|
||||
|> Enum.map(&{&1, true})
|
||||
|> Enum.into(%{})
|
||||
|
||||
send(self(), {:fields_selected, all})
|
||||
{:noreply, assign(socket, :selected_fields, all)}
|
||||
end
|
||||
|
||||
# select none
|
||||
def handle_event("select_none", _params, socket) do
|
||||
none =
|
||||
socket.assigns.all_fields
|
||||
|> Enum.map(&field_to_string/1)
|
||||
|> Enum.map(&{&1, false})
|
||||
|> Enum.into(%{})
|
||||
|
||||
send(self(), {:fields_selected, none})
|
||||
{:noreply, assign(socket, :selected_fields, none)}
|
||||
end
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# HELPERS (with defensive nil guards)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
defp extract_member_field_keys(nil), do: []
|
||||
|
||||
defp extract_member_field_keys(fields) do
|
||||
prefix = Mv.Constants.custom_field_prefix()
|
||||
|
||||
Enum.filter(fields, fn field ->
|
||||
is_atom(field) ||
|
||||
(is_binary(field) && not String.starts_with?(field, prefix))
|
||||
end)
|
||||
end
|
||||
|
||||
defp extract_custom_field_keys(nil), do: []
|
||||
|
||||
defp extract_custom_field_keys(fields) do
|
||||
prefix = Mv.Constants.custom_field_prefix()
|
||||
|
||||
Enum.filter(fields, fn field ->
|
||||
is_binary(field) && String.starts_with?(field, prefix)
|
||||
end)
|
||||
end
|
||||
|
||||
defp field_to_string(field) when is_atom(field), do: Atom.to_string(field)
|
||||
defp field_to_string(field) when is_binary(field), do: field
|
||||
|
||||
defp format_field_label(field) do
|
||||
field
|
||||
|> field_to_string()
|
||||
|> String.replace("_", " ")
|
||||
|> String.split()
|
||||
|> Enum.map_join(" ", &String.capitalize/1)
|
||||
end
|
||||
|
||||
defp format_custom_field_label(field_string, custom_fields) do
|
||||
id = String.trim_leading(field_string, Mv.Constants.custom_field_prefix())
|
||||
find_custom_field_name(id, field_string, custom_fields)
|
||||
end
|
||||
|
||||
defp find_custom_field_name("", field_string, _custom_fields), do: field_string
|
||||
|
||||
defp find_custom_field_name(id, _field_string, custom_fields) do
|
||||
case Enum.find(custom_fields, fn cf -> to_string(cf.id) == id end) do
|
||||
nil -> gettext("Custom Field %{id}", id: id)
|
||||
custom_field -> custom_field.name
|
||||
end
|
||||
end
|
||||
end
|
||||
146
lib/mv_web/live/components/payment_filter_component.ex
Normal file
146
lib/mv_web/live/components/payment_filter_component.ex
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
defmodule MvWeb.Components.PaymentFilterComponent do
|
||||
@moduledoc """
|
||||
Provides the PaymentFilter Live-Component.
|
||||
|
||||
A dropdown filter for filtering members by payment status (paid/not paid/all).
|
||||
Uses DaisyUI dropdown styling and sends filter changes to parent LiveView.
|
||||
|
||||
## Props
|
||||
- `:paid_filter` - Current filter state: `nil` (all), `:paid`, or `:not_paid`
|
||||
- `:id` - Component ID (required)
|
||||
- `:member_count` - Number of filtered members to display in badge (optional, default: 0)
|
||||
|
||||
## Events
|
||||
- Sends `{:payment_filter_changed, filter}` to parent when filter changes
|
||||
"""
|
||||
use MvWeb, :live_component
|
||||
|
||||
@impl true
|
||||
def mount(socket) do
|
||||
{:ok, assign(socket, :open, false)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def update(assigns, socket) do
|
||||
socket =
|
||||
socket
|
||||
|> assign(:id, assigns.id)
|
||||
|> assign(:paid_filter, assigns[:paid_filter])
|
||||
|> assign(:member_count, assigns[:member_count] || 0)
|
||||
|
||||
{:ok, socket}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<div
|
||||
class="relative"
|
||||
id={@id}
|
||||
phx-window-keydown={@open && "close_dropdown"}
|
||||
phx-key="Escape"
|
||||
phx-target={@myself}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class={[
|
||||
"btn btn-ghost gap-2",
|
||||
@paid_filter && "btn-active"
|
||||
]}
|
||||
phx-click="toggle_dropdown"
|
||||
phx-target={@myself}
|
||||
aria-haspopup="true"
|
||||
aria-expanded={to_string(@open)}
|
||||
aria-label={gettext("Filter by payment status")}
|
||||
>
|
||||
<.icon name="hero-funnel" class="h-5 w-5" />
|
||||
<span class="hidden sm:inline">{filter_label(@paid_filter)}</span>
|
||||
<span :if={@paid_filter} class="badge badge-primary badge-sm">{@member_count}</span>
|
||||
</button>
|
||||
|
||||
<ul
|
||||
:if={@open}
|
||||
class="menu dropdown-content bg-base-100 rounded-box z-10 w-52 p-2 shadow-lg absolute right-0 mt-2"
|
||||
role="menu"
|
||||
aria-label={gettext("Payment filter")}
|
||||
phx-click-away="close_dropdown"
|
||||
phx-target={@myself}
|
||||
>
|
||||
<li role="none">
|
||||
<button
|
||||
type="button"
|
||||
role="menuitemradio"
|
||||
aria-checked={to_string(@paid_filter == nil)}
|
||||
class={@paid_filter == nil && "active"}
|
||||
phx-click="select_filter"
|
||||
phx-value-filter=""
|
||||
phx-target={@myself}
|
||||
>
|
||||
<.icon name="hero-users" class="h-4 w-4" />
|
||||
{gettext("All")}
|
||||
</button>
|
||||
</li>
|
||||
<li role="none">
|
||||
<button
|
||||
type="button"
|
||||
role="menuitemradio"
|
||||
aria-checked={to_string(@paid_filter == :paid)}
|
||||
class={@paid_filter == :paid && "active"}
|
||||
phx-click="select_filter"
|
||||
phx-value-filter="paid"
|
||||
phx-target={@myself}
|
||||
>
|
||||
<.icon name="hero-check-circle" class="h-4 w-4 text-success" />
|
||||
{gettext("Paid")}
|
||||
</button>
|
||||
</li>
|
||||
<li role="none">
|
||||
<button
|
||||
type="button"
|
||||
role="menuitemradio"
|
||||
aria-checked={to_string(@paid_filter == :not_paid)}
|
||||
class={@paid_filter == :not_paid && "active"}
|
||||
phx-click="select_filter"
|
||||
phx-value-filter="not_paid"
|
||||
phx-target={@myself}
|
||||
>
|
||||
<.icon name="hero-x-circle" class="h-4 w-4 text-error" />
|
||||
{gettext("Not paid")}
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("toggle_dropdown", _params, socket) do
|
||||
{:noreply, assign(socket, :open, !socket.assigns.open)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("close_dropdown", _params, socket) do
|
||||
{:noreply, assign(socket, :open, false)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("select_filter", %{"filter" => filter_str}, socket) do
|
||||
filter = parse_filter(filter_str)
|
||||
|
||||
# Close dropdown and notify parent
|
||||
socket = assign(socket, :open, false)
|
||||
send(self(), {:payment_filter_changed, filter})
|
||||
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
# Parse filter string to atom
|
||||
defp parse_filter("paid"), do: :paid
|
||||
defp parse_filter("not_paid"), do: :not_paid
|
||||
defp parse_filter(_), do: nil
|
||||
|
||||
# Get display label for current filter
|
||||
defp filter_label(nil), do: gettext("All")
|
||||
defp filter_label(:paid), do: gettext("Paid")
|
||||
defp filter_label(:not_paid), do: gettext("Not paid")
|
||||
end
|
||||
|
|
@ -19,7 +19,7 @@ defmodule MvWeb.Components.SortHeaderComponent do
|
|||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<div class="tooltip" data-tip={aria_sort(@field, @sort_field, @sort_order)}>
|
||||
<div class="tooltip tooltip-bottom" data-tip={aria_sort(@field, @sort_field, @sort_order)}>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={aria_sort(@field, @sort_field, @sort_order)}
|
||||
|
|
|
|||
345
lib/mv_web/live/contribution_period_live/show.ex
Normal file
345
lib/mv_web/live/contribution_period_live/show.ex
Normal file
|
|
@ -0,0 +1,345 @@
|
|||
defmodule MvWeb.ContributionPeriodLive.Show do
|
||||
@moduledoc """
|
||||
Mock-up LiveView for Member Contribution Periods (Admin/Treasurer View).
|
||||
|
||||
This is a preview-only page that displays the planned UI for viewing
|
||||
and managing contribution periods for a specific member.
|
||||
It shows static mock data and is not functional.
|
||||
|
||||
## Planned Features (Future Implementation)
|
||||
- Display all contribution periods for a member
|
||||
- Show period dates, interval, amount, and status
|
||||
- Quick status change (paid/unpaid/suspended)
|
||||
- Bulk marking of multiple periods
|
||||
- Notes per period
|
||||
|
||||
## Note
|
||||
This page is intentionally non-functional and serves as a UI mockup
|
||||
for the upcoming Membership Contributions feature.
|
||||
"""
|
||||
use MvWeb, :live_view
|
||||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(:page_title, gettext("Member Contributions"))
|
||||
|> assign(:member, mock_member())
|
||||
|> assign(:periods, mock_periods())
|
||||
|> assign(:selected_periods, MapSet.new())}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<Layouts.app flash={@flash} current_user={@current_user}>
|
||||
<.mockup_warning />
|
||||
|
||||
<.header>
|
||||
{gettext("Contributions for %{name}", name: "#{@member.first_name} #{@member.last_name}")}
|
||||
<:subtitle>
|
||||
{gettext("Contribution type")}:
|
||||
<span class="font-semibold">{@member.contribution_type}</span>
|
||||
· {gettext("Member since")}: <span class="font-mono">{@member.joined_at}</span>
|
||||
</:subtitle>
|
||||
<:actions>
|
||||
<.link navigate={~p"/contribution_settings"} class="btn btn-ghost btn-sm">
|
||||
<.icon name="hero-arrow-left" class="size-4" />
|
||||
{gettext("Back to Settings")}
|
||||
</.link>
|
||||
</:actions>
|
||||
</.header>
|
||||
|
||||
<%!-- Member Info Card --%>
|
||||
<div class="mb-6 shadow card bg-base-100">
|
||||
<div class="card-body">
|
||||
<div class="grid grid-cols-2 gap-4 md:grid-cols-4">
|
||||
<div>
|
||||
<span class="text-sm text-base-content/60">{gettext("Email")}</span>
|
||||
<p class="font-medium">{@member.email}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-sm text-base-content/60">{gettext("Contribution Start")}</span>
|
||||
<p class="font-mono">{@member.contribution_start}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-sm text-base-content/60">{gettext("Total Contributions")}</span>
|
||||
<p class="font-semibold">{length(@periods)}</p>
|
||||
</div>
|
||||
<div>
|
||||
<span class="text-sm text-base-content/60">{gettext("Open Contributions")}</span>
|
||||
<p class="font-semibold text-error">
|
||||
{Enum.count(@periods, &(&1.status == :unpaid))}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%!-- Contribution Type Change --%>
|
||||
<div class="mb-6 card bg-base-200">
|
||||
<div class="py-4 card-body">
|
||||
<div class="flex flex-wrap items-center gap-4">
|
||||
<span class="font-semibold">{gettext("Change Contribution Type")}:</span>
|
||||
<select class="w-64 select select-bordered select-sm" disabled>
|
||||
<option selected>{@member.contribution_type} (60,00 €, {gettext("Yearly")})</option>
|
||||
<option>{gettext("Reduced")} (30,00 €, {gettext("Yearly")})</option>
|
||||
<option>{gettext("Honorary")} (0,00 €, {gettext("Yearly")})</option>
|
||||
</select>
|
||||
<span
|
||||
class="text-sm text-base-content/60 cursor-help tooltip tooltip-bottom"
|
||||
data-tip={
|
||||
gettext(
|
||||
"Members can only switch between contribution types with the same payment interval (e.g., yearly to yearly). This prevents complex period overlaps."
|
||||
)
|
||||
}
|
||||
>
|
||||
<.icon name="hero-question-mark-circle" class="inline size-4" />
|
||||
{gettext("Why are not all contribution types shown?")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%!-- Bulk Actions --%>
|
||||
<div class="flex flex-wrap items-center gap-4 mb-4">
|
||||
<span class="text-sm text-base-content/60">
|
||||
{ngettext(
|
||||
"%{count} period selected",
|
||||
"%{count} periods selected",
|
||||
MapSet.size(@selected_periods),
|
||||
count: MapSet.size(@selected_periods)
|
||||
)}
|
||||
</span>
|
||||
<button class="btn btn-sm btn-success" disabled>
|
||||
<.icon name="hero-check" class="size-4" />
|
||||
{gettext("Mark as Paid")}
|
||||
</button>
|
||||
<button class="btn btn-sm btn-ghost" disabled>
|
||||
<.icon name="hero-minus-circle" class="size-4" />
|
||||
{gettext("Mark as Suspended")}
|
||||
</button>
|
||||
<button class="btn btn-sm btn-ghost" disabled>
|
||||
<.icon name="hero-x-circle" class="size-4" />
|
||||
{gettext("Mark as Unpaid")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<%!-- Periods Table --%>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="table table-zebra">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>
|
||||
<input type="checkbox" class="checkbox checkbox-sm" disabled />
|
||||
</th>
|
||||
<th>{gettext("Time Period")}</th>
|
||||
<th>{gettext("Interval")}</th>
|
||||
<th>{gettext("Amount")}</th>
|
||||
<th>{gettext("Status")}</th>
|
||||
<th>{gettext("Notes")}</th>
|
||||
<th>{gettext("Actions")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr :for={period <- @periods} class={period_row_class(period.status)}>
|
||||
<td>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="checkbox checkbox-sm"
|
||||
checked={MapSet.member?(@selected_periods, period.id)}
|
||||
disabled
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
<div class="font-mono">
|
||||
{period.period_start} – {period.period_end}
|
||||
</div>
|
||||
<div :if={period.is_current} class="mt-1 badge badge-info badge-sm">
|
||||
{gettext("Current")}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge badge-outline badge-sm">{format_interval(period.interval)}</span>
|
||||
</td>
|
||||
<td>
|
||||
<span class="font-mono">{format_currency(period.amount)}</span>
|
||||
</td>
|
||||
<td>
|
||||
<.status_badge status={period.status} />
|
||||
</td>
|
||||
<td>
|
||||
<span :if={period.notes} class="text-sm italic text-base-content/60">
|
||||
{period.notes}
|
||||
</span>
|
||||
<span :if={!period.notes} class="text-base-content/30">—</span>
|
||||
</td>
|
||||
<td class="w-0 font-semibold whitespace-nowrap">
|
||||
<div class="flex gap-4">
|
||||
<.link
|
||||
href="#"
|
||||
class={[
|
||||
"cursor-not-allowed",
|
||||
if(period.status == :paid, do: "invisible", else: "opacity-50")
|
||||
]}
|
||||
>
|
||||
{gettext("Paid")}
|
||||
</.link>
|
||||
<.link
|
||||
href="#"
|
||||
class={[
|
||||
"cursor-not-allowed",
|
||||
if(period.status == :suspended, do: "invisible", else: "opacity-50")
|
||||
]}
|
||||
>
|
||||
{gettext("Suspend")}
|
||||
</.link>
|
||||
<.link
|
||||
href="#"
|
||||
class={[
|
||||
"cursor-not-allowed",
|
||||
if(period.status != :paid, do: "invisible", else: "opacity-50")
|
||||
]}
|
||||
>
|
||||
{gettext("Reopen")}
|
||||
</.link>
|
||||
<.link href="#" class="opacity-50 cursor-not-allowed">
|
||||
{gettext("Note")}
|
||||
</.link>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</Layouts.app>
|
||||
"""
|
||||
end
|
||||
|
||||
# Mock-up warning banner component - subtle orange style
|
||||
defp mockup_warning(assigns) do
|
||||
~H"""
|
||||
<div class="flex items-center gap-3 px-4 py-3 mb-6 border rounded-lg border-warning text-warning bg-base-100">
|
||||
<.icon name="hero-exclamation-triangle" class="size-5 shrink-0" />
|
||||
<div>
|
||||
<span class="font-semibold">{gettext("Preview Mockup")}</span>
|
||||
<span class="ml-2 text-sm text-base-content/70">
|
||||
– {gettext("This page is not functional and only displays the planned features.")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
# Status badge component
|
||||
attr :status, :atom, required: true
|
||||
|
||||
defp status_badge(%{status: :paid} = assigns) do
|
||||
~H"""
|
||||
<span class="gap-1 badge badge-success">
|
||||
<.icon name="hero-check-circle-mini" class="size-3" />
|
||||
{gettext("Paid")}
|
||||
</span>
|
||||
"""
|
||||
end
|
||||
|
||||
defp status_badge(%{status: :unpaid} = assigns) do
|
||||
~H"""
|
||||
<span class="gap-1 badge badge-error">
|
||||
<.icon name="hero-x-circle-mini" class="size-3" />
|
||||
{gettext("Unpaid")}
|
||||
</span>
|
||||
"""
|
||||
end
|
||||
|
||||
defp status_badge(%{status: :suspended} = assigns) do
|
||||
~H"""
|
||||
<span class="gap-1 badge badge-neutral">
|
||||
<.icon name="hero-pause-circle-mini" class="size-3" />
|
||||
{gettext("Suspended")}
|
||||
</span>
|
||||
"""
|
||||
end
|
||||
|
||||
defp period_row_class(:unpaid), do: "bg-error/5"
|
||||
defp period_row_class(:suspended), do: "bg-base-200/50"
|
||||
defp period_row_class(_), do: ""
|
||||
|
||||
# Mock member data
|
||||
defp mock_member do
|
||||
%{
|
||||
id: "123",
|
||||
first_name: "Maria",
|
||||
last_name: "Weber",
|
||||
email: "maria.weber@example.de",
|
||||
contribution_type: gettext("Regular"),
|
||||
joined_at: "15.03.2021",
|
||||
contribution_start: "01.01.2021"
|
||||
}
|
||||
end
|
||||
|
||||
# Mock periods data
|
||||
defp mock_periods do
|
||||
[
|
||||
%{
|
||||
id: "p1",
|
||||
period_start: "01.01.2025",
|
||||
period_end: "31.12.2025",
|
||||
interval: :yearly,
|
||||
amount: Decimal.new("60.00"),
|
||||
status: :unpaid,
|
||||
notes: nil,
|
||||
is_current: true
|
||||
},
|
||||
%{
|
||||
id: "p2",
|
||||
period_start: "01.01.2024",
|
||||
period_end: "31.12.2024",
|
||||
interval: :yearly,
|
||||
amount: Decimal.new("60.00"),
|
||||
status: :paid,
|
||||
notes: gettext("Paid via bank transfer"),
|
||||
is_current: false
|
||||
},
|
||||
%{
|
||||
id: "p3",
|
||||
period_start: "01.01.2023",
|
||||
period_end: "31.12.2023",
|
||||
interval: :yearly,
|
||||
amount: Decimal.new("50.00"),
|
||||
status: :paid,
|
||||
notes: nil,
|
||||
is_current: false
|
||||
},
|
||||
%{
|
||||
id: "p4",
|
||||
period_start: "01.01.2022",
|
||||
period_end: "31.12.2022",
|
||||
interval: :yearly,
|
||||
amount: Decimal.new("50.00"),
|
||||
status: :paid,
|
||||
notes: nil,
|
||||
is_current: false
|
||||
},
|
||||
%{
|
||||
id: "p5",
|
||||
period_start: "01.01.2021",
|
||||
period_end: "31.12.2021",
|
||||
interval: :yearly,
|
||||
amount: Decimal.new("50.00"),
|
||||
status: :suspended,
|
||||
notes: gettext("Joining year - reduced to 0"),
|
||||
is_current: false
|
||||
}
|
||||
]
|
||||
end
|
||||
|
||||
defp format_currency(%Decimal{} = amount) do
|
||||
"#{Decimal.to_string(amount)} €"
|
||||
end
|
||||
|
||||
defp format_interval(:monthly), do: gettext("Monthly")
|
||||
defp format_interval(:quarterly), do: gettext("Quarterly")
|
||||
defp format_interval(:half_yearly), do: gettext("Half-yearly")
|
||||
defp format_interval(:yearly), do: gettext("Yearly")
|
||||
end
|
||||
277
lib/mv_web/live/contribution_settings_live.ex
Normal file
277
lib/mv_web/live/contribution_settings_live.ex
Normal file
|
|
@ -0,0 +1,277 @@
|
|||
defmodule MvWeb.ContributionSettingsLive do
|
||||
@moduledoc """
|
||||
Mock-up LiveView for Contribution Settings (Admin).
|
||||
|
||||
This is a preview-only page that displays the planned UI for managing
|
||||
global contribution settings. It shows static mock data and is not functional.
|
||||
|
||||
## Planned Features (Future Implementation)
|
||||
- Set default contribution type for new members
|
||||
- Configure whether joining period is included in contributions
|
||||
- Explanatory text with examples
|
||||
|
||||
## Settings
|
||||
- `default_contribution_type_id` - UUID of the default contribution type
|
||||
- `include_joining_period` - Boolean whether to include joining period
|
||||
|
||||
## Note
|
||||
This page is intentionally non-functional and serves as a UI mockup
|
||||
for the upcoming Membership Contributions feature.
|
||||
"""
|
||||
use MvWeb, :live_view
|
||||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(:page_title, gettext("Contribution Settings"))
|
||||
|> assign(:contribution_types, mock_contribution_types())
|
||||
|> assign(:selected_type_id, "1")
|
||||
|> assign(:include_joining_period, true)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<Layouts.app flash={@flash} current_user={@current_user}>
|
||||
<.mockup_warning />
|
||||
|
||||
<.header>
|
||||
{gettext("Contribution Settings")}
|
||||
<:subtitle>
|
||||
{gettext("Configure global settings for membership contributions.")}
|
||||
</:subtitle>
|
||||
</.header>
|
||||
|
||||
<div class="grid gap-6 lg:grid-cols-2">
|
||||
<%!-- Settings Form --%>
|
||||
<div class="card bg-base-100 shadow-xl">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">
|
||||
<.icon name="hero-cog-6-tooth" class="size-5" />
|
||||
{gettext("Global Settings")}
|
||||
</h2>
|
||||
|
||||
<form class="space-y-6">
|
||||
<%!-- Default Contribution Type --%>
|
||||
<fieldset class="fieldset">
|
||||
<label class="label">
|
||||
<span class="label-text font-semibold">
|
||||
{gettext("Default Contribution Type")}
|
||||
</span>
|
||||
</label>
|
||||
<select class="select select-bordered w-full" disabled>
|
||||
<option :for={ct <- @contribution_types} selected={ct.id == @selected_type_id}>
|
||||
{ct.name} ({format_currency(ct.amount)}, {format_interval(ct.interval)})
|
||||
</option>
|
||||
</select>
|
||||
<p class="text-sm text-base-content/60 mt-2">
|
||||
{gettext(
|
||||
"This contribution type is automatically assigned to all new members. Can be changed individually per member."
|
||||
)}
|
||||
</p>
|
||||
</fieldset>
|
||||
|
||||
<%!-- Include Joining Period --%>
|
||||
<fieldset class="fieldset">
|
||||
<label class="label cursor-pointer justify-start gap-3">
|
||||
<input
|
||||
type="checkbox"
|
||||
class="checkbox checkbox-primary"
|
||||
checked={@include_joining_period}
|
||||
disabled
|
||||
/>
|
||||
<span class="label-text font-semibold">
|
||||
{gettext("Include joining period")}
|
||||
</span>
|
||||
</label>
|
||||
<div class="ml-9 space-y-2">
|
||||
<p class="text-sm text-base-content/60">
|
||||
{gettext("When active: Members pay from the period of their joining.")}
|
||||
</p>
|
||||
<p class="text-sm text-base-content/60">
|
||||
{gettext("When inactive: Members pay from the next full period after joining.")}
|
||||
</p>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<button type="button" class="btn btn-primary w-full" disabled>
|
||||
<.icon name="hero-check" class="size-5" />
|
||||
{gettext("Save Settings")}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%!-- Examples Card --%>
|
||||
<div class="card bg-base-200">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">
|
||||
<.icon name="hero-light-bulb" class="size-5" />
|
||||
{gettext("Examples")}
|
||||
</h2>
|
||||
|
||||
<.example_section
|
||||
title={gettext("Yearly Interval - Joining Period Included")}
|
||||
joining_date="15.03.2023"
|
||||
include_joining={true}
|
||||
start_date="01.01.2023"
|
||||
periods={["2023", "2024", "2025"]}
|
||||
note={gettext("Member pays for the year they joined")}
|
||||
/>
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<.example_section
|
||||
title={gettext("Yearly Interval - Joining Period Excluded")}
|
||||
joining_date="15.03.2023"
|
||||
include_joining={false}
|
||||
start_date="01.01.2024"
|
||||
periods={["2024", "2025"]}
|
||||
note={gettext("Member pays from the next full year")}
|
||||
/>
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<.example_section
|
||||
title={gettext("Quarterly Interval - Joining Period Excluded")}
|
||||
joining_date="15.05.2024"
|
||||
include_joining={false}
|
||||
start_date="01.07.2024"
|
||||
periods={["Q3/2024", "Q4/2024", "Q1/2025"]}
|
||||
note={gettext("Member pays from the next full quarter")}
|
||||
/>
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<.example_section
|
||||
title={gettext("Monthly Interval - Joining Period Included")}
|
||||
joining_date="15.03.2024"
|
||||
include_joining={true}
|
||||
start_date="01.03.2024"
|
||||
periods={["03/2024", "04/2024", "05/2024", "..."]}
|
||||
note={gettext("Member pays from the joining month")}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<.example_member_card />
|
||||
</Layouts.app>
|
||||
"""
|
||||
end
|
||||
|
||||
# Example member card with link to period view
|
||||
defp example_member_card(assigns) do
|
||||
~H"""
|
||||
<div class="card bg-base-100 shadow-xl mt-6">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">
|
||||
<.icon name="hero-user" class="size-5" />
|
||||
{gettext("Example: Member Contribution View")}
|
||||
</h2>
|
||||
<p class="text-base-content/70">
|
||||
{gettext(
|
||||
"See how the contribution periods will be displayed for an individual member. This example shows Maria Weber with multiple contribution periods."
|
||||
)}
|
||||
</p>
|
||||
<div class="card-actions justify-end">
|
||||
<.link navigate={~p"/contributions/member/example"} class="btn btn-primary btn-sm">
|
||||
<.icon name="hero-eye" class="size-4" />
|
||||
{gettext("View Example Member")}
|
||||
</.link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
# Mock-up warning banner component - subtle orange style
|
||||
defp mockup_warning(assigns) do
|
||||
~H"""
|
||||
<div class="border border-warning text-warning bg-base-100 rounded-lg px-4 py-3 mb-6 flex items-center gap-3">
|
||||
<.icon name="hero-exclamation-triangle" class="size-5 shrink-0" />
|
||||
<div>
|
||||
<span class="font-semibold">{gettext("Preview Mockup")}</span>
|
||||
<span class="text-sm text-base-content/70 ml-2">
|
||||
– {gettext("This page is not functional and only displays the planned features.")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
# Example section component
|
||||
attr :title, :string, required: true
|
||||
attr :joining_date, :string, required: true
|
||||
attr :include_joining, :boolean, required: true
|
||||
attr :start_date, :string, required: true
|
||||
attr :periods, :list, required: true
|
||||
attr :note, :string, required: true
|
||||
|
||||
defp example_section(assigns) do
|
||||
~H"""
|
||||
<div class="space-y-2">
|
||||
<h3 class="font-semibold text-sm">{@title}</h3>
|
||||
<div class="bg-base-300 rounded-lg p-3 text-sm space-y-1">
|
||||
<p>
|
||||
<span class="text-base-content/60">{gettext("Joining date")}:</span>
|
||||
<span class="font-mono">{@joining_date}</span>
|
||||
</p>
|
||||
<p>
|
||||
<span class="text-base-content/60">{gettext("Contribution start")}:</span>
|
||||
<span class="font-mono font-semibold text-primary">{@start_date}</span>
|
||||
</p>
|
||||
<p>
|
||||
<span class="text-base-content/60">{gettext("Generated periods")}:</span>
|
||||
<span class="font-mono">
|
||||
{Enum.join(@periods, ", ")}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
<p class="text-xs text-base-content/60 italic">→ {@note}</p>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
# Mock data for demonstration
|
||||
defp mock_contribution_types do
|
||||
[
|
||||
%{
|
||||
id: "1",
|
||||
name: gettext("Regular"),
|
||||
amount: Decimal.new("60.00"),
|
||||
interval: :yearly
|
||||
},
|
||||
%{
|
||||
id: "2",
|
||||
name: gettext("Reduced"),
|
||||
amount: Decimal.new("30.00"),
|
||||
interval: :yearly
|
||||
},
|
||||
%{
|
||||
id: "3",
|
||||
name: gettext("Student"),
|
||||
amount: Decimal.new("5.00"),
|
||||
interval: :monthly
|
||||
},
|
||||
%{
|
||||
id: "4",
|
||||
name: gettext("Family"),
|
||||
amount: Decimal.new("25.00"),
|
||||
interval: :quarterly
|
||||
}
|
||||
]
|
||||
end
|
||||
|
||||
defp format_currency(%Decimal{} = amount) do
|
||||
"#{Decimal.to_string(amount)} €"
|
||||
end
|
||||
|
||||
defp format_interval(:monthly), do: gettext("Monthly")
|
||||
defp format_interval(:quarterly), do: gettext("Quarterly")
|
||||
defp format_interval(:half_yearly), do: gettext("Half-yearly")
|
||||
defp format_interval(:yearly), do: gettext("Yearly")
|
||||
end
|
||||
205
lib/mv_web/live/contribution_type_live/index.ex
Normal file
205
lib/mv_web/live/contribution_type_live/index.ex
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
defmodule MvWeb.ContributionTypeLive.Index do
|
||||
@moduledoc """
|
||||
Mock-up LiveView for Contribution Types Management (Admin).
|
||||
|
||||
This is a preview-only page that displays the planned UI for managing
|
||||
contribution types. It shows static mock data and is not functional.
|
||||
|
||||
## Planned Features (Future Implementation)
|
||||
- List all contribution types
|
||||
- Display: Name, Amount, Interval, Member count
|
||||
- Create new contribution types
|
||||
- Edit existing contribution types (name, amount, description - NOT interval)
|
||||
- Delete contribution types (if no members assigned)
|
||||
|
||||
## Note
|
||||
This page is intentionally non-functional and serves as a UI mockup
|
||||
for the upcoming Membership Contributions feature.
|
||||
"""
|
||||
use MvWeb, :live_view
|
||||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(:page_title, gettext("Contribution Types"))
|
||||
|> assign(:contribution_types, mock_contribution_types())}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<Layouts.app flash={@flash} current_user={@current_user}>
|
||||
<.mockup_warning />
|
||||
|
||||
<.header>
|
||||
{gettext("Contribution Types")}
|
||||
<:subtitle>
|
||||
{gettext("Manage contribution types for membership fees.")}
|
||||
</:subtitle>
|
||||
<:actions>
|
||||
<button class="btn btn-primary" disabled>
|
||||
<.icon name="hero-plus" /> {gettext("New Contribution Type")}
|
||||
</button>
|
||||
</:actions>
|
||||
</.header>
|
||||
|
||||
<.table id="contribution_types" rows={@contribution_types} row_id={fn ct -> "ct-#{ct.id}" end}>
|
||||
<:col :let={ct} label={gettext("Name")}>
|
||||
<span class="font-medium">{ct.name}</span>
|
||||
<p :if={ct.description} class="text-sm text-base-content/60">{ct.description}</p>
|
||||
</:col>
|
||||
|
||||
<:col :let={ct} label={gettext("Amount")}>
|
||||
<span class="font-mono">{format_currency(ct.amount)}</span>
|
||||
</:col>
|
||||
|
||||
<:col :let={ct} label={gettext("Interval")}>
|
||||
<span class="badge badge-outline">{format_interval(ct.interval)}</span>
|
||||
</:col>
|
||||
|
||||
<:col :let={ct} label={gettext("Members")}>
|
||||
<span class="badge badge-ghost">{ct.member_count}</span>
|
||||
</:col>
|
||||
|
||||
<:action :let={_ct}>
|
||||
<button class="btn btn-ghost btn-xs" disabled title={gettext("Edit")}>
|
||||
<.icon name="hero-pencil" class="size-4" />
|
||||
</button>
|
||||
</:action>
|
||||
|
||||
<:action :let={ct}>
|
||||
<button
|
||||
class="btn btn-ghost btn-xs text-error"
|
||||
disabled
|
||||
title={
|
||||
if ct.member_count > 0,
|
||||
do: gettext("Cannot delete - members assigned"),
|
||||
else: gettext("Delete")
|
||||
}
|
||||
>
|
||||
<.icon name="hero-trash" class="size-4" />
|
||||
</button>
|
||||
</:action>
|
||||
</.table>
|
||||
|
||||
<.info_card />
|
||||
</Layouts.app>
|
||||
"""
|
||||
end
|
||||
|
||||
# Mock-up warning banner component - subtle orange style
|
||||
defp mockup_warning(assigns) do
|
||||
~H"""
|
||||
<div class="border border-warning text-warning bg-base-100 rounded-lg px-4 py-3 mb-6 flex items-center gap-3">
|
||||
<.icon name="hero-exclamation-triangle" class="size-5 shrink-0" />
|
||||
<div>
|
||||
<span class="font-semibold">{gettext("Preview Mockup")}</span>
|
||||
<span class="text-sm text-base-content/70 ml-2">
|
||||
– {gettext("This page is not functional and only displays the planned features.")}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
# Info card explaining the contribution type concept
|
||||
defp info_card(assigns) do
|
||||
~H"""
|
||||
<div class="card bg-base-200 mt-6">
|
||||
<div class="card-body">
|
||||
<h2 class="card-title">
|
||||
<.icon name="hero-information-circle" class="size-5" />
|
||||
{gettext("About Contribution Types")}
|
||||
</h2>
|
||||
<div class="prose prose-sm max-w-none">
|
||||
<p>
|
||||
{gettext(
|
||||
"Contribution types define different membership fee structures. Each type has a fixed interval (monthly, quarterly, half-yearly, yearly) that cannot be changed after creation."
|
||||
)}
|
||||
</p>
|
||||
<ul>
|
||||
<li>
|
||||
<strong>{gettext("Name & Amount")}</strong>
|
||||
- {gettext("Can be changed at any time. Amount changes affect future periods only.")}
|
||||
</li>
|
||||
<li>
|
||||
<strong>{gettext("Interval")}</strong>
|
||||
- {gettext(
|
||||
"Fixed after creation. Members can only switch between types with the same interval."
|
||||
)}
|
||||
</li>
|
||||
<li>
|
||||
<strong>{gettext("Deletion")}</strong>
|
||||
- {gettext("Only possible if no members are assigned to this type.")}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
# Mock data for demonstration
|
||||
defp mock_contribution_types do
|
||||
[
|
||||
%{
|
||||
id: "1",
|
||||
name: gettext("Regular"),
|
||||
description: gettext("Standard membership fee for regular members"),
|
||||
amount: Decimal.new("60.00"),
|
||||
interval: :yearly,
|
||||
member_count: 45
|
||||
},
|
||||
%{
|
||||
id: "2",
|
||||
name: gettext("Reduced"),
|
||||
description: gettext("Reduced fee for unemployed, pensioners, or low income"),
|
||||
amount: Decimal.new("30.00"),
|
||||
interval: :yearly,
|
||||
member_count: 12
|
||||
},
|
||||
%{
|
||||
id: "3",
|
||||
name: gettext("Student"),
|
||||
description: gettext("Monthly fee for students and trainees"),
|
||||
amount: Decimal.new("5.00"),
|
||||
interval: :monthly,
|
||||
member_count: 8
|
||||
},
|
||||
%{
|
||||
id: "4",
|
||||
name: gettext("Family"),
|
||||
description: gettext("Quarterly fee for family memberships"),
|
||||
amount: Decimal.new("25.00"),
|
||||
interval: :quarterly,
|
||||
member_count: 15
|
||||
},
|
||||
%{
|
||||
id: "5",
|
||||
name: gettext("Supporting Member"),
|
||||
description: gettext("Half-yearly contribution for supporting members"),
|
||||
amount: Decimal.new("100.00"),
|
||||
interval: :half_yearly,
|
||||
member_count: 3
|
||||
},
|
||||
%{
|
||||
id: "6",
|
||||
name: gettext("Honorary"),
|
||||
description: gettext("No fee for honorary members"),
|
||||
amount: Decimal.new("0.00"),
|
||||
interval: :yearly,
|
||||
member_count: 2
|
||||
}
|
||||
]
|
||||
end
|
||||
|
||||
defp format_currency(%Decimal{} = amount) do
|
||||
"#{Decimal.to_string(amount)} €"
|
||||
end
|
||||
|
||||
defp format_interval(:monthly), do: gettext("Monthly")
|
||||
defp format_interval(:quarterly), do: gettext("Quarterly")
|
||||
defp format_interval(:half_yearly), do: gettext("Half-yearly")
|
||||
defp format_interval(:yearly), do: gettext("Yearly")
|
||||
end
|
||||
|
|
@ -1,140 +0,0 @@
|
|||
defmodule MvWeb.CustomFieldLive.Form do
|
||||
@moduledoc """
|
||||
LiveView form for creating and editing custom fields (admin).
|
||||
|
||||
## Features
|
||||
- Create new custom field definitions
|
||||
- Edit existing custom fields
|
||||
- Select value type from supported types
|
||||
- Set immutable and required flags
|
||||
- Real-time validation
|
||||
|
||||
## Form Fields
|
||||
**Required:**
|
||||
- name - Unique identifier (e.g., "phone_mobile", "emergency_contact")
|
||||
- value_type - Data type (:string, :integer, :boolean, :date, :email)
|
||||
|
||||
**Optional:**
|
||||
- description - Human-readable explanation
|
||||
- immutable - If true, values cannot be changed after creation (default: false)
|
||||
- required - If true, all members must have this custom field (default: false)
|
||||
|
||||
## Value Type Selection
|
||||
- `:string` - Text data (unlimited length)
|
||||
- `:integer` - Numeric data
|
||||
- `:boolean` - True/false flags
|
||||
- `:date` - Date values
|
||||
- `:email` - Validated email addresses
|
||||
|
||||
## Events
|
||||
- `validate` - Real-time form validation
|
||||
- `save` - Submit form (create or update custom field)
|
||||
|
||||
## Security
|
||||
Custom field management is restricted to admin users.
|
||||
"""
|
||||
use MvWeb, :live_view
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<Layouts.app flash={@flash} current_user={@current_user}>
|
||||
<.header>
|
||||
{@page_title}
|
||||
<:subtitle>
|
||||
{gettext("Use this form to manage custom_field records in your database.")}
|
||||
</:subtitle>
|
||||
</.header>
|
||||
|
||||
<.form for={@form} id="custom_field-form" phx-change="validate" phx-submit="save">
|
||||
<.input field={@form[:name]} type="text" label={gettext("Name")} />
|
||||
|
||||
<.input
|
||||
field={@form[:value_type]}
|
||||
type="select"
|
||||
label={gettext("Value type")}
|
||||
options={
|
||||
Ash.Resource.Info.attribute(Mv.Membership.CustomField, :value_type).constraints[:one_of]
|
||||
}
|
||||
/>
|
||||
<.input field={@form[:description]} type="text" label={gettext("Description")} />
|
||||
<.input field={@form[:immutable]} type="checkbox" label={gettext("Immutable")} />
|
||||
<.input field={@form[:required]} type="checkbox" label={gettext("Required")} />
|
||||
|
||||
<.button phx-disable-with={gettext("Saving...")} variant="primary">
|
||||
{gettext("Save Custom field")}
|
||||
</.button>
|
||||
<.button navigate={return_path(@return_to, @custom_field)}>{gettext("Cancel")}</.button>
|
||||
</.form>
|
||||
</Layouts.app>
|
||||
"""
|
||||
end
|
||||
|
||||
@impl true
|
||||
def mount(params, _session, socket) do
|
||||
custom_field =
|
||||
case params["id"] do
|
||||
nil -> nil
|
||||
id -> Ash.get!(Mv.Membership.CustomField, id)
|
||||
end
|
||||
|
||||
action = if is_nil(custom_field), do: "New", else: "Edit"
|
||||
page_title = action <> " " <> "Custom field"
|
||||
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(:return_to, return_to(params["return_to"]))
|
||||
|> assign(custom_field: custom_field)
|
||||
|> assign(:page_title, page_title)
|
||||
|> assign_form()}
|
||||
end
|
||||
|
||||
defp return_to("show"), do: "show"
|
||||
defp return_to(_), do: "index"
|
||||
|
||||
@impl true
|
||||
def handle_event("validate", %{"custom_field" => custom_field_params}, socket) do
|
||||
{:noreply,
|
||||
assign(socket, form: AshPhoenix.Form.validate(socket.assigns.form, custom_field_params))}
|
||||
end
|
||||
|
||||
def handle_event("save", %{"custom_field" => custom_field_params}, socket) do
|
||||
case AshPhoenix.Form.submit(socket.assigns.form, params: custom_field_params) do
|
||||
{:ok, custom_field} ->
|
||||
notify_parent({:saved, custom_field})
|
||||
|
||||
action =
|
||||
case socket.assigns.form.source.type do
|
||||
:create -> gettext("create")
|
||||
:update -> gettext("update")
|
||||
other -> to_string(other)
|
||||
end
|
||||
|
||||
socket =
|
||||
socket
|
||||
|> put_flash(:info, gettext("Custom field %{action} successfully", action: action))
|
||||
|> push_navigate(to: return_path(socket.assigns.return_to, custom_field))
|
||||
|
||||
{:noreply, socket}
|
||||
|
||||
{:error, form} ->
|
||||
{:noreply, assign(socket, form: form)}
|
||||
end
|
||||
end
|
||||
|
||||
defp notify_parent(msg), do: send(self(), {__MODULE__, msg})
|
||||
|
||||
defp assign_form(%{assigns: %{custom_field: custom_field}} = socket) do
|
||||
form =
|
||||
if custom_field do
|
||||
AshPhoenix.Form.for_update(custom_field, :update, as: "custom_field")
|
||||
else
|
||||
AshPhoenix.Form.for_create(Mv.Membership.CustomField, :create, as: "custom_field")
|
||||
end
|
||||
|
||||
assign(socket, form: to_form(form))
|
||||
end
|
||||
|
||||
defp return_path("index", _custom_field), do: ~p"/custom_fields"
|
||||
defp return_path("show", custom_field), do: ~p"/custom_fields/#{custom_field.id}"
|
||||
end
|
||||
127
lib/mv_web/live/custom_field_live/form_component.ex
Normal file
127
lib/mv_web/live/custom_field_live/form_component.ex
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
defmodule MvWeb.CustomFieldLive.FormComponent do
|
||||
@moduledoc """
|
||||
LiveComponent form for creating and editing custom fields (embedded in settings).
|
||||
|
||||
## Features
|
||||
- Create new custom field definitions
|
||||
- Edit existing custom fields
|
||||
- Select value type from supported types
|
||||
- Set immutable and required flags
|
||||
- Real-time validation
|
||||
|
||||
## Props
|
||||
- `custom_field` - The custom field to edit (nil for new)
|
||||
- `on_save` - Callback function to call when form is saved
|
||||
- `on_cancel` - Callback function to call when form is cancelled
|
||||
"""
|
||||
use MvWeb, :live_component
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<div id={@id} class="mb-8 border shadow-xl card border-base-300">
|
||||
<div class="card-body">
|
||||
<div class="flex items-center gap-4 mb-4">
|
||||
<.button
|
||||
type="button"
|
||||
phx-click="cancel"
|
||||
phx-target={@myself}
|
||||
aria-label={gettext("Back to custom field overview")}
|
||||
>
|
||||
<.icon name="hero-arrow-left" class="w-4 h-4" />
|
||||
</.button>
|
||||
<h3 class="card-title">
|
||||
{if @custom_field, do: gettext("Edit Custom Field"), else: gettext("New Custom Field")}
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<.form
|
||||
for={@form}
|
||||
id={@id <> "-form"}
|
||||
phx-change="validate"
|
||||
phx-submit="save"
|
||||
phx-target={@myself}
|
||||
>
|
||||
<.input field={@form[:name]} type="text" label={gettext("Name")} />
|
||||
|
||||
<.input
|
||||
field={@form[:value_type]}
|
||||
type="select"
|
||||
label={gettext("Value type")}
|
||||
options={
|
||||
Ash.Resource.Info.attribute(Mv.Membership.CustomField, :value_type).constraints[:one_of]
|
||||
}
|
||||
/>
|
||||
<.input field={@form[:description]} type="text" label={gettext("Description")} />
|
||||
<.input field={@form[:immutable]} type="checkbox" label={gettext("Immutable")} />
|
||||
<.input field={@form[:required]} type="checkbox" label={gettext("Required")} />
|
||||
<.input
|
||||
field={@form[:show_in_overview]}
|
||||
type="checkbox"
|
||||
label={gettext("Show in overview")}
|
||||
/>
|
||||
|
||||
<div class="justify-end mt-4 card-actions">
|
||||
<.button type="button" phx-click="cancel" phx-target={@myself}>
|
||||
{gettext("Cancel")}
|
||||
</.button>
|
||||
<.button phx-disable-with={gettext("Saving...")} variant="primary">
|
||||
{gettext("Save Custom field")}
|
||||
</.button>
|
||||
</div>
|
||||
</.form>
|
||||
</div>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
@impl true
|
||||
def update(assigns, socket) do
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(assigns)
|
||||
|> assign_form()}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("validate", %{"custom_field" => custom_field_params}, socket) do
|
||||
{:noreply,
|
||||
assign(socket, form: AshPhoenix.Form.validate(socket.assigns.form, custom_field_params))}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("save", %{"custom_field" => custom_field_params}, socket) do
|
||||
case AshPhoenix.Form.submit(socket.assigns.form, params: custom_field_params) do
|
||||
{:ok, custom_field} ->
|
||||
action =
|
||||
case socket.assigns.form.source.type do
|
||||
:create -> gettext("create")
|
||||
:update -> gettext("update")
|
||||
other -> to_string(other)
|
||||
end
|
||||
|
||||
socket.assigns.on_save.(custom_field, action)
|
||||
{:noreply, socket}
|
||||
|
||||
{:error, form} ->
|
||||
{:noreply, assign(socket, form: form)}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("cancel", _params, socket) do
|
||||
socket.assigns.on_cancel.()
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
defp assign_form(%{assigns: %{custom_field: custom_field}} = socket) do
|
||||
form =
|
||||
if custom_field do
|
||||
AshPhoenix.Form.for_update(custom_field, :update, as: "custom_field")
|
||||
else
|
||||
AshPhoenix.Form.for_create(Mv.Membership.CustomField, :create, as: "custom_field")
|
||||
end
|
||||
|
||||
assign(socket, form: to_form(form))
|
||||
end
|
||||
end
|
||||
|
|
@ -1,199 +0,0 @@
|
|||
defmodule MvWeb.CustomFieldLive.Index do
|
||||
@moduledoc """
|
||||
LiveView for managing custom field definitions (admin).
|
||||
|
||||
## Features
|
||||
- List all custom fields
|
||||
- Display type information (name, value type, description)
|
||||
- Show immutable and required flags
|
||||
- Create new custom fields
|
||||
- Edit existing custom fields
|
||||
- Delete custom fields with confirmation (cascades to all custom field values)
|
||||
|
||||
## Displayed Information
|
||||
- Name: Unique identifier for the custom field
|
||||
- Value type: Data type constraint (string, integer, boolean, date, email)
|
||||
- Description: Human-readable explanation
|
||||
- Immutable: Whether custom field values can be changed after creation
|
||||
- Required: Whether all members must have this custom field (future feature)
|
||||
|
||||
## Events
|
||||
- `prepare_delete` - Opens deletion confirmation modal with member count
|
||||
- `confirm_delete` - Executes deletion after slug verification
|
||||
- `cancel_delete` - Cancels deletion and closes modal
|
||||
- `update_slug_confirmation` - Updates slug input state
|
||||
|
||||
## Security
|
||||
Custom field management is restricted to admin users.
|
||||
Deletion requires entering the custom field's slug to prevent accidental deletions.
|
||||
"""
|
||||
use MvWeb, :live_view
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<Layouts.app flash={@flash} current_user={@current_user}>
|
||||
<.header>
|
||||
Listing Custom fields
|
||||
<:actions>
|
||||
<.button variant="primary" navigate={~p"/custom_fields/new"}>
|
||||
<.icon name="hero-plus" /> New Custom field
|
||||
</.button>
|
||||
</:actions>
|
||||
</.header>
|
||||
|
||||
<.table
|
||||
id="custom_fields"
|
||||
rows={@streams.custom_fields}
|
||||
row_click={fn {_id, custom_field} -> JS.navigate(~p"/custom_fields/#{custom_field}") end}
|
||||
>
|
||||
<:col :let={{_id, custom_field}} label="Name">{custom_field.name}</:col>
|
||||
|
||||
<:col :let={{_id, custom_field}} label="Description">{custom_field.description}</:col>
|
||||
|
||||
<:action :let={{_id, custom_field}}>
|
||||
<div class="sr-only">
|
||||
<.link navigate={~p"/custom_fields/#{custom_field}"}>Show</.link>
|
||||
</div>
|
||||
|
||||
<.link navigate={~p"/custom_fields/#{custom_field}/edit"}>Edit</.link>
|
||||
</:action>
|
||||
|
||||
<:action :let={{_id, custom_field}}>
|
||||
<.link phx-click={JS.push("prepare_delete", value: %{id: custom_field.id})}>
|
||||
Delete
|
||||
</.link>
|
||||
</:action>
|
||||
</.table>
|
||||
|
||||
<%!-- Delete Confirmation Modal --%>
|
||||
<dialog :if={@show_delete_modal} id="delete-custom-field-modal" class="modal modal-open">
|
||||
<div class="modal-box">
|
||||
<h3 class="font-bold text-lg">{gettext("Delete Custom Field")}</h3>
|
||||
|
||||
<div class="py-4 space-y-4">
|
||||
<div class="alert alert-warning">
|
||||
<.icon name="hero-exclamation-triangle" class="h-5 w-5" />
|
||||
<div>
|
||||
<p class="font-semibold">
|
||||
{ngettext(
|
||||
"%{count} member has a value assigned for this custom field.",
|
||||
"%{count} members have values assigned for this custom field.",
|
||||
@custom_field_to_delete.assigned_members_count,
|
||||
count: @custom_field_to_delete.assigned_members_count
|
||||
)}
|
||||
</p>
|
||||
<p class="text-sm mt-2">
|
||||
{gettext(
|
||||
"All custom field values will be permanently deleted when you delete this custom field."
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="slug-confirmation" class="label">
|
||||
<span class="label-text">
|
||||
{gettext("To confirm deletion, please enter this text:")}
|
||||
</span>
|
||||
</label>
|
||||
<div class="font-mono font-bold text-lg mb-2 p-2 bg-base-200 rounded break-all">
|
||||
{@custom_field_to_delete.slug}
|
||||
</div>
|
||||
<form phx-change="update_slug_confirmation">
|
||||
<input
|
||||
id="slug-confirmation"
|
||||
name="slug"
|
||||
type="text"
|
||||
value={@slug_confirmation}
|
||||
placeholder={gettext("Enter the text above to confirm")}
|
||||
autocomplete="off"
|
||||
phx-mounted={JS.focus()}
|
||||
class="input input-bordered w-full"
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-action">
|
||||
<button phx-click="cancel_delete" class="btn">
|
||||
{gettext("Cancel")}
|
||||
</button>
|
||||
<button
|
||||
phx-click="confirm_delete"
|
||||
class="btn btn-error"
|
||||
disabled={@slug_confirmation != @custom_field_to_delete.slug}
|
||||
>
|
||||
{gettext("Delete Custom Field and All Values")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
</Layouts.app>
|
||||
"""
|
||||
end
|
||||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(:page_title, "Listing Custom fields")
|
||||
|> assign(:show_delete_modal, false)
|
||||
|> assign(:custom_field_to_delete, nil)
|
||||
|> assign(:slug_confirmation, "")
|
||||
|> stream(:custom_fields, Ash.read!(Mv.Membership.CustomField))}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("prepare_delete", %{"id" => id}, socket) do
|
||||
custom_field = Ash.get!(Mv.Membership.CustomField, id, load: [:assigned_members_count])
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:custom_field_to_delete, custom_field)
|
||||
|> assign(:show_delete_modal, true)
|
||||
|> assign(:slug_confirmation, "")}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("update_slug_confirmation", %{"slug" => slug}, socket) do
|
||||
{:noreply, assign(socket, :slug_confirmation, slug)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("confirm_delete", _params, socket) do
|
||||
custom_field = socket.assigns.custom_field_to_delete
|
||||
|
||||
if socket.assigns.slug_confirmation == custom_field.slug do
|
||||
# Delete the custom field (CASCADE will handle custom field values)
|
||||
case Ash.destroy(custom_field) do
|
||||
:ok ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:info, "Custom field deleted successfully")
|
||||
|> assign(:show_delete_modal, false)
|
||||
|> assign(:custom_field_to_delete, nil)
|
||||
|> assign(:slug_confirmation, "")
|
||||
|> stream_delete(:custom_fields, custom_field)}
|
||||
|
||||
{:error, error} ->
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:error, "Failed to delete custom field: #{inspect(error)}")}
|
||||
end
|
||||
else
|
||||
{:noreply,
|
||||
socket
|
||||
|> put_flash(:error, "Slug does not match. Deletion cancelled.")}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("cancel_delete", _params, socket) do
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:show_delete_modal, false)
|
||||
|> assign(:custom_field_to_delete, nil)
|
||||
|> assign(:slug_confirmation, "")}
|
||||
end
|
||||
end
|
||||
261
lib/mv_web/live/custom_field_live/index_component.ex
Normal file
261
lib/mv_web/live/custom_field_live/index_component.ex
Normal file
|
|
@ -0,0 +1,261 @@
|
|||
defmodule MvWeb.CustomFieldLive.IndexComponent do
|
||||
@moduledoc """
|
||||
LiveComponent for managing custom field definitions (embedded in settings).
|
||||
|
||||
## Features
|
||||
- List all custom fields
|
||||
- Display type information (name, value type, description)
|
||||
- Show immutable and required flags
|
||||
- Create new custom fields
|
||||
- Edit existing custom fields
|
||||
- Delete custom fields with confirmation (cascades to all custom field values)
|
||||
"""
|
||||
use MvWeb, :live_component
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<div id={@id}>
|
||||
<.header>
|
||||
{gettext("Custom Fields")}
|
||||
<:subtitle>
|
||||
{gettext("These will appear in addition to other data when adding new members.")}
|
||||
</:subtitle>
|
||||
<:actions>
|
||||
<.button variant="primary" phx-click="new_custom_field" phx-target={@myself}>
|
||||
<.icon name="hero-plus" /> {gettext("New Custom field")}
|
||||
</.button>
|
||||
</:actions>
|
||||
</.header>
|
||||
|
||||
<%!-- Show form when creating or editing --%>
|
||||
<div :if={@show_form} class="mb-8">
|
||||
<.live_component
|
||||
module={MvWeb.CustomFieldLive.FormComponent}
|
||||
id={@form_id}
|
||||
custom_field={@editing_custom_field}
|
||||
on_save={
|
||||
fn custom_field, action -> send(self(), {:custom_field_saved, custom_field, action}) end
|
||||
}
|
||||
on_cancel={fn -> send_update(__MODULE__, id: @id, show_form: false) end}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<%!-- Hide table when form is visible --%>
|
||||
<.table
|
||||
:if={!@show_form}
|
||||
id="custom_fields"
|
||||
rows={@streams.custom_fields}
|
||||
row_click={
|
||||
fn {_id, custom_field} ->
|
||||
JS.push("edit_custom_field", value: %{id: custom_field.id}, target: @myself)
|
||||
end
|
||||
}
|
||||
>
|
||||
<:col :let={{_id, custom_field}} label={gettext("Name")}>{custom_field.name}</:col>
|
||||
|
||||
<:col :let={{_id, custom_field}} label={gettext("Value Type")}>
|
||||
{custom_field.value_type}
|
||||
</:col>
|
||||
|
||||
<:col :let={{_id, custom_field}} label={gettext("Description")}>
|
||||
{custom_field.description}
|
||||
</:col>
|
||||
|
||||
<:col :let={{_id, custom_field}} label={gettext("Show in Overview")}>
|
||||
<span :if={custom_field.show_in_overview} class="badge badge-success">
|
||||
{gettext("Yes")}
|
||||
</span>
|
||||
<span :if={!custom_field.show_in_overview} class="badge badge-ghost">
|
||||
{gettext("No")}
|
||||
</span>
|
||||
</:col>
|
||||
|
||||
<:action :let={{_id, custom_field}}>
|
||||
<.link phx-click={
|
||||
JS.push("edit_custom_field", value: %{id: custom_field.id}, target: @myself)
|
||||
}>
|
||||
{gettext("Edit")}
|
||||
</.link>
|
||||
</:action>
|
||||
|
||||
<:action :let={{_id, custom_field}}>
|
||||
<.link phx-click={JS.push("prepare_delete", value: %{id: custom_field.id}, target: @myself)}>
|
||||
{gettext("Delete")}
|
||||
</.link>
|
||||
</:action>
|
||||
</.table>
|
||||
|
||||
<%!-- Delete Confirmation Modal --%>
|
||||
<dialog :if={@show_delete_modal} id="delete-custom-field-modal" class="modal modal-open">
|
||||
<div class="modal-box">
|
||||
<h3 class="text-lg font-bold">{gettext("Delete Custom Field")}</h3>
|
||||
|
||||
<div class="py-4 space-y-4">
|
||||
<div class="alert alert-warning">
|
||||
<.icon name="hero-exclamation-triangle" class="w-5 h-5" />
|
||||
<div>
|
||||
<p class="font-semibold">
|
||||
{ngettext(
|
||||
"%{count} member has a value assigned for this custom field.",
|
||||
"%{count} members have values assigned for this custom field.",
|
||||
@custom_field_to_delete.assigned_members_count,
|
||||
count: @custom_field_to_delete.assigned_members_count
|
||||
)}
|
||||
</p>
|
||||
<p class="mt-2 text-sm">
|
||||
{gettext(
|
||||
"All custom field values will be permanently deleted when you delete this custom field."
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="slug-confirmation" class="label">
|
||||
<span class="label-text">
|
||||
{gettext("To confirm deletion, please enter this text:")}
|
||||
</span>
|
||||
</label>
|
||||
<div class="p-2 mb-2 font-mono text-lg font-bold break-all rounded bg-base-200">
|
||||
{@custom_field_to_delete.slug}
|
||||
</div>
|
||||
<form phx-change="update_slug_confirmation" phx-target={@myself}>
|
||||
<input
|
||||
id="slug-confirmation"
|
||||
name="slug"
|
||||
type="text"
|
||||
value={@slug_confirmation}
|
||||
placeholder={gettext("Enter the text above to confirm")}
|
||||
autocomplete="off"
|
||||
phx-mounted={JS.focus()}
|
||||
class="w-full input input-bordered"
|
||||
/>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-action">
|
||||
<button phx-click="cancel_delete" phx-target={@myself} class="btn">
|
||||
{gettext("Cancel")}
|
||||
</button>
|
||||
<button
|
||||
phx-click="confirm_delete"
|
||||
phx-target={@myself}
|
||||
class="btn btn-error"
|
||||
disabled={@slug_confirmation != @custom_field_to_delete.slug}
|
||||
>
|
||||
{gettext("Delete Custom Field and All Values")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
@impl true
|
||||
def update(assigns, socket) do
|
||||
# If show_form is explicitly provided in assigns, reset editing state
|
||||
socket =
|
||||
if Map.has_key?(assigns, :show_form) and assigns.show_form == false do
|
||||
socket
|
||||
|> assign(:editing_custom_field, nil)
|
||||
|> assign(:form_id, "custom-field-form-new")
|
||||
else
|
||||
socket
|
||||
end
|
||||
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(assigns)
|
||||
|> assign_new(:show_form, fn -> false end)
|
||||
|> assign_new(:form_id, fn -> "custom-field-form-new" end)
|
||||
|> assign_new(:editing_custom_field, fn -> nil end)
|
||||
|> assign_new(:show_delete_modal, fn -> false end)
|
||||
|> assign_new(:custom_field_to_delete, fn -> nil end)
|
||||
|> assign_new(:slug_confirmation, fn -> "" end)
|
||||
|> stream(:custom_fields, Ash.read!(Mv.Membership.CustomField), reset: true)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("new_custom_field", _params, socket) do
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:show_form, true)
|
||||
|> assign(:editing_custom_field, nil)
|
||||
|> assign(:form_id, "custom-field-form-new")}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("edit_custom_field", %{"id" => id}, socket) do
|
||||
custom_field = Ash.get!(Mv.Membership.CustomField, id)
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:show_form, true)
|
||||
|> assign(:editing_custom_field, custom_field)
|
||||
|> assign(:form_id, "custom-field-form-#{id}")}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("prepare_delete", %{"id" => id}, socket) do
|
||||
custom_field = Ash.get!(Mv.Membership.CustomField, id, load: [:assigned_members_count])
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:custom_field_to_delete, custom_field)
|
||||
|> assign(:show_delete_modal, true)
|
||||
|> assign(:slug_confirmation, "")}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("update_slug_confirmation", %{"slug" => slug}, socket) do
|
||||
{:noreply, assign(socket, :slug_confirmation, slug)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("confirm_delete", _params, socket) do
|
||||
custom_field = socket.assigns.custom_field_to_delete
|
||||
|
||||
if socket.assigns.slug_confirmation == custom_field.slug do
|
||||
case Ash.destroy(custom_field) do
|
||||
:ok ->
|
||||
send(self(), {:custom_field_deleted, custom_field})
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:show_delete_modal, false)
|
||||
|> assign(:custom_field_to_delete, nil)
|
||||
|> assign(:slug_confirmation, "")
|
||||
|> stream_delete(:custom_fields, custom_field)}
|
||||
|
||||
{:error, error} ->
|
||||
send(self(), {:custom_field_delete_error, error})
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:show_delete_modal, false)
|
||||
|> assign(:custom_field_to_delete, nil)
|
||||
|> assign(:slug_confirmation, "")}
|
||||
end
|
||||
else
|
||||
send(self(), :custom_field_slug_mismatch)
|
||||
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:show_delete_modal, false)
|
||||
|> assign(:custom_field_to_delete, nil)
|
||||
|> assign(:slug_confirmation, "")}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("cancel_delete", _params, socket) do
|
||||
{:noreply,
|
||||
socket
|
||||
|> assign(:show_delete_modal, false)
|
||||
|> assign(:custom_field_to_delete, nil)
|
||||
|> assign(:slug_confirmation, "")}
|
||||
end
|
||||
end
|
||||
|
|
@ -1,75 +0,0 @@
|
|||
defmodule MvWeb.CustomFieldLive.Show do
|
||||
@moduledoc """
|
||||
LiveView for displaying a single custom field's details (admin).
|
||||
|
||||
## Features
|
||||
- Display custom field definition
|
||||
- Show all attributes (name, value type, description, flags)
|
||||
- Navigate to edit form
|
||||
- Return to custom field list
|
||||
|
||||
## Displayed Information
|
||||
- ID: Internal UUID identifier
|
||||
- Slug: URL-friendly identifier (auto-generated, immutable)
|
||||
- Name: Unique identifier
|
||||
- Value type: Data type constraint
|
||||
- Description: Optional explanation
|
||||
- Immutable flag: Whether values can be changed
|
||||
- Required flag: Whether all members need this custom field
|
||||
|
||||
## Navigation
|
||||
- Back to custom field list
|
||||
- Edit custom field
|
||||
|
||||
## Security
|
||||
Custom field details are restricted to admin users.
|
||||
"""
|
||||
use MvWeb, :live_view
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<Layouts.app flash={@flash} current_user={@current_user}>
|
||||
<.header>
|
||||
Custom field {@custom_field.slug}
|
||||
<:subtitle>This is a custom_field record from your database.</:subtitle>
|
||||
|
||||
<:actions>
|
||||
<.button navigate={~p"/custom_fields"}>
|
||||
<.icon name="hero-arrow-left" />
|
||||
</.button>
|
||||
<.button
|
||||
variant="primary"
|
||||
navigate={~p"/custom_fields/#{@custom_field}/edit?return_to=show"}
|
||||
>
|
||||
<.icon name="hero-pencil-square" /> Edit Custom field
|
||||
</.button>
|
||||
</:actions>
|
||||
</.header>
|
||||
|
||||
<.list>
|
||||
<:item title="Id">{@custom_field.id}</:item>
|
||||
|
||||
<:item title="Slug">
|
||||
{@custom_field.slug}
|
||||
<p class="mt-2 text-sm leading-6 text-zinc-600">
|
||||
{gettext("Auto-generated identifier (immutable)")}
|
||||
</p>
|
||||
</:item>
|
||||
|
||||
<:item title="Name">{@custom_field.name}</:item>
|
||||
|
||||
<:item title="Description">{@custom_field.description}</:item>
|
||||
</.list>
|
||||
</Layouts.app>
|
||||
"""
|
||||
end
|
||||
|
||||
@impl true
|
||||
def mount(%{"id" => id}, _session, socket) do
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(:page_title, "Show Custom field")
|
||||
|> assign(:custom_field, Ash.get!(Mv.Membership.CustomField, id))}
|
||||
end
|
||||
end
|
||||
140
lib/mv_web/live/global_settings_live.ex
Normal file
140
lib/mv_web/live/global_settings_live.ex
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
defmodule MvWeb.GlobalSettingsLive do
|
||||
@moduledoc """
|
||||
LiveView for managing global application settings (Vereinsdaten).
|
||||
|
||||
## Features
|
||||
- Edit the association/club name
|
||||
- Manage custom fields
|
||||
- Real-time form validation
|
||||
- Success/error feedback
|
||||
|
||||
## Settings
|
||||
- `club_name` - The name of the association/club (required)
|
||||
|
||||
## Events
|
||||
- `validate` - Real-time form validation
|
||||
- `save` - Save settings changes
|
||||
|
||||
## Note
|
||||
Settings is a singleton resource - there is only one settings record.
|
||||
The club_name can also be set via the `ASSOCIATION_NAME` environment variable.
|
||||
"""
|
||||
use MvWeb, :live_view
|
||||
|
||||
alias Mv.Membership
|
||||
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
{:ok, settings} = Membership.get_settings()
|
||||
|
||||
{:ok,
|
||||
socket
|
||||
|> assign(:page_title, gettext("Settings"))
|
||||
|> assign(:settings, settings)
|
||||
|> assign_form()}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
~H"""
|
||||
<Layouts.app flash={@flash} current_user={@current_user}>
|
||||
<.header>
|
||||
{gettext("Settings")}
|
||||
<:subtitle>
|
||||
{gettext("Manage global settings for the association.")}
|
||||
</:subtitle>
|
||||
</.header>
|
||||
|
||||
<%!-- Club Settings Section --%>
|
||||
<.header>
|
||||
{gettext("Club Settings")}
|
||||
</.header>
|
||||
<.form for={@form} id="settings-form" phx-change="validate" phx-submit="save">
|
||||
<.input
|
||||
field={@form[:club_name]}
|
||||
type="text"
|
||||
label={gettext("Association Name")}
|
||||
required
|
||||
/>
|
||||
|
||||
<.button phx-disable-with={gettext("Saving...")} variant="primary">
|
||||
{gettext("Save Settings")}
|
||||
</.button>
|
||||
</.form>
|
||||
|
||||
<%!-- Custom Fields Section --%>
|
||||
<.live_component
|
||||
module={MvWeb.CustomFieldLive.IndexComponent}
|
||||
id="custom-fields-component"
|
||||
/>
|
||||
</Layouts.app>
|
||||
"""
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("validate", %{"setting" => setting_params}, socket) do
|
||||
{:noreply,
|
||||
assign(socket, form: AshPhoenix.Form.validate(socket.assigns.form, setting_params))}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_event("save", %{"setting" => setting_params}, socket) do
|
||||
case AshPhoenix.Form.submit(socket.assigns.form, params: setting_params) do
|
||||
{:ok, updated_settings} ->
|
||||
socket =
|
||||
socket
|
||||
|> assign(:settings, updated_settings)
|
||||
|> put_flash(:info, gettext("Settings updated successfully"))
|
||||
|> assign_form()
|
||||
|
||||
{:noreply, socket}
|
||||
|
||||
{:error, form} ->
|
||||
{:noreply, assign(socket, form: form)}
|
||||
end
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info({:custom_field_saved, _custom_field, action}, socket) do
|
||||
send_update(MvWeb.CustomFieldLive.IndexComponent,
|
||||
id: "custom-fields-component",
|
||||
show_form: false
|
||||
)
|
||||
|
||||
{:noreply,
|
||||
put_flash(socket, :info, gettext("Custom field %{action} successfully", action: action))}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info({:custom_field_deleted, _custom_field}, socket) do
|
||||
{:noreply, put_flash(socket, :info, gettext("Custom field deleted successfully"))}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info({:custom_field_delete_error, error}, socket) do
|
||||
{:noreply,
|
||||
put_flash(
|
||||
socket,
|
||||
:error,
|
||||
gettext("Failed to delete custom field: %{error}", error: inspect(error))
|
||||
)}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info(:custom_field_slug_mismatch, socket) do
|
||||
{:noreply, put_flash(socket, :error, gettext("Slug does not match. Deletion cancelled."))}
|
||||
end
|
||||
|
||||
defp assign_form(%{assigns: %{settings: settings}} = socket) do
|
||||
form =
|
||||
AshPhoenix.Form.for_update(
|
||||
settings,
|
||||
:update,
|
||||
api: Membership,
|
||||
as: "setting",
|
||||
forms: [auto?: true]
|
||||
)
|
||||
|
||||
assign(socket, form: to_form(form))
|
||||
end
|
||||
end
|
||||
|
|
@ -5,81 +5,212 @@ defmodule MvWeb.MemberLive.Form do
|
|||
## Features
|
||||
- Create new members with personal information
|
||||
- Edit existing member details
|
||||
- Manage custom properties (dynamic fields)
|
||||
- Grouped sections for better organization
|
||||
- Tab navigation (Payments tab disabled, coming soon)
|
||||
- Manage custom properties (dynamic fields, displayed sorted by name)
|
||||
- Real-time validation with visual feedback
|
||||
- Link/unlink user accounts
|
||||
|
||||
## Form Fields
|
||||
**Required:**
|
||||
- first_name, last_name, email
|
||||
|
||||
**Optional:**
|
||||
- birth_date, phone_number, address fields (city, street, house_number, postal_code)
|
||||
- join_date, exit_date
|
||||
- paid status
|
||||
- notes
|
||||
|
||||
## Custom Field Values
|
||||
Members can have dynamic custom field values defined by CustomFields.
|
||||
The form dynamically renders inputs based on available CustomFields.
|
||||
## Form Sections
|
||||
- Personal Data: Name, address, contact information, membership dates, notes
|
||||
- Custom Fields: Dynamic fields in uniform grid layout (displayed sorted by name)
|
||||
- Payment Data: Mockup section (not editable)
|
||||
|
||||
## Events
|
||||
- `validate` - Real-time form validation
|
||||
- `save` - Submit form (create or update member)
|
||||
- Custom field value management events for adding/removing custom fields
|
||||
"""
|
||||
use MvWeb, :live_view
|
||||
|
||||
@impl true
|
||||
def render(assigns) do
|
||||
# Sort custom fields by name for display only
|
||||
sorted_custom_fields = Enum.sort_by(assigns.custom_fields, & &1.name)
|
||||
assigns = assign(assigns, :sorted_custom_fields, sorted_custom_fields)
|
||||
|
||||
~H"""
|
||||
<Layouts.app flash={@flash} current_user={@current_user}>
|
||||
<.header>
|
||||
{@page_title}
|
||||
<:subtitle>
|
||||
{gettext("Use this form to manage member records and their properties.")}
|
||||
</:subtitle>
|
||||
</.header>
|
||||
|
||||
<.form for={@form} id="member-form" phx-change="validate" phx-submit="save">
|
||||
<.input field={@form[:first_name]} label={gettext("First Name")} required />
|
||||
<.input field={@form[:last_name]} label={gettext("Last Name")} required />
|
||||
<.input field={@form[:email]} label={gettext("Email")} required type="email" />
|
||||
<.input field={@form[:birth_date]} label={gettext("Birth Date")} type="date" />
|
||||
<.input field={@form[:paid]} label={gettext("Paid")} type="checkbox" />
|
||||
<.input field={@form[:phone_number]} label={gettext("Phone Number")} />
|
||||
<.input field={@form[:join_date]} label={gettext("Join Date")} type="date" />
|
||||
<.input field={@form[:exit_date]} label={gettext("Exit Date")} type="date" />
|
||||
<.input field={@form[:notes]} label={gettext("Notes")} />
|
||||
<.input field={@form[:city]} label={gettext("City")} />
|
||||
<.input field={@form[:street]} label={gettext("Street")} />
|
||||
<.input field={@form[:house_number]} label={gettext("House Number")} />
|
||||
<.input field={@form[:postal_code]} label={gettext("Postal Code")} />
|
||||
<%!-- Header with Back button, Name display, and Save button --%>
|
||||
<div class="flex items-center justify-between gap-4 pb-4">
|
||||
<.button navigate={return_path(@return_to, @member)} type="button">
|
||||
<.icon name="hero-arrow-left" class="size-4" />
|
||||
{gettext("Back")}
|
||||
</.button>
|
||||
|
||||
<h3 class="mt-8 mb-2 text-lg font-semibold">{gettext("Custom Field Values")}</h3>
|
||||
<.inputs_for :let={f_custom_field_value} field={@form[:custom_field_values]}>
|
||||
<% type =
|
||||
Enum.find(@custom_fields, &(&1.id == f_custom_field_value[:custom_field_id].value)) %>
|
||||
<.inputs_for :let={value_form} field={f_custom_field_value[:value]}>
|
||||
<% input_type =
|
||||
cond do
|
||||
type && type.value_type == :boolean -> "checkbox"
|
||||
type && type.value_type == :date -> :date
|
||||
true -> :text
|
||||
end %>
|
||||
<.input field={value_form[:value]} label={type && type.name} type={input_type} />
|
||||
</.inputs_for>
|
||||
<input
|
||||
type="hidden"
|
||||
name={f_custom_field_value[:custom_field_id].name}
|
||||
value={f_custom_field_value[:custom_field_id].value}
|
||||
/>
|
||||
</.inputs_for>
|
||||
<h1 class="text-2xl font-bold text-center flex-1">
|
||||
<%= if @member do %>
|
||||
{@member.first_name} {@member.last_name}
|
||||
<% else %>
|
||||
{gettext("New Member")}
|
||||
<% end %>
|
||||
</h1>
|
||||
|
||||
<.button phx-disable-with={gettext("Saving...")} variant="primary">
|
||||
{gettext("Save Member")}
|
||||
</.button>
|
||||
<.button navigate={return_path(@return_to, @member)}>{gettext("Cancel")}</.button>
|
||||
<.button phx-disable-with={gettext("Saving...")} variant="primary" type="submit">
|
||||
{gettext("Save")}
|
||||
</.button>
|
||||
</div>
|
||||
|
||||
<%!-- Tab Navigation --%>
|
||||
<div role="tablist" class="tabs tabs-bordered mb-6">
|
||||
<button type="button" role="tab" class="tab tab-active" aria-selected="true">
|
||||
<.icon name="hero-identification" class="size-4 mr-2" />
|
||||
{gettext("Contact Data")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
class="tab"
|
||||
disabled
|
||||
aria-disabled="true"
|
||||
title={gettext("Coming soon")}
|
||||
>
|
||||
<.icon name="hero-credit-card" class="size-4 mr-2" />
|
||||
{gettext("Payments")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<%!-- Personal Data and Custom Fields Row --%>
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
|
||||
<%!-- Personal Data Section --%>
|
||||
<div>
|
||||
<.form_section title={gettext("Personal Data")}>
|
||||
<div class="space-y-4">
|
||||
<%!-- Name Row --%>
|
||||
<div class="flex gap-4">
|
||||
<div class="w-48">
|
||||
<.input field={@form[:first_name]} label={gettext("First Name")} required />
|
||||
</div>
|
||||
<div class="w-48">
|
||||
<.input field={@form[:last_name]} label={gettext("Last Name")} required />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%!-- Address Row --%>
|
||||
<div class="flex gap-4">
|
||||
<div class="flex-1">
|
||||
<.input field={@form[:street]} label={gettext("Street")} />
|
||||
</div>
|
||||
<div class="w-16">
|
||||
<.input field={@form[:house_number]} label={gettext("Nr.")} />
|
||||
</div>
|
||||
<div class="w-24">
|
||||
<.input field={@form[:postal_code]} label={gettext("Postal Code")} />
|
||||
</div>
|
||||
<div class="w-32">
|
||||
<.input field={@form[:city]} label={gettext("City")} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%!-- Email --%>
|
||||
<div>
|
||||
<.input field={@form[:email]} label={gettext("Email")} required type="email" />
|
||||
</div>
|
||||
|
||||
<%!-- Phone --%>
|
||||
<div>
|
||||
<.input field={@form[:phone_number]} label={gettext("Phone")} type="tel" />
|
||||
</div>
|
||||
|
||||
<%!-- Membership Dates Row --%>
|
||||
<div class="flex gap-4">
|
||||
<div class="w-36">
|
||||
<.input field={@form[:join_date]} label={gettext("Join Date")} type="date" />
|
||||
</div>
|
||||
<div class="w-36">
|
||||
<.input field={@form[:exit_date]} label={gettext("Exit Date")} type="date" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<%!-- Notes --%>
|
||||
<div>
|
||||
<.input field={@form[:notes]} label={gettext("Notes")} type="textarea" />
|
||||
</div>
|
||||
</div>
|
||||
</.form_section>
|
||||
</div>
|
||||
|
||||
<%!-- Custom Fields Section --%>
|
||||
<%= if Enum.any?(@custom_fields) do %>
|
||||
<div>
|
||||
<.form_section title={gettext("Custom Fields")}>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<%!-- Render in sorted order by finding the form for each sorted custom field --%>
|
||||
<%= for cf <- @sorted_custom_fields do %>
|
||||
<.inputs_for :let={f_cfv} field={@form[:custom_field_values]}>
|
||||
<%= if f_cfv[:custom_field_id].value == cf.id do %>
|
||||
<div class={if cf.value_type == :boolean, do: "flex items-end", else: ""}>
|
||||
<.inputs_for :let={value_form} field={f_cfv[:value]}>
|
||||
<.input
|
||||
field={value_form[:value]}
|
||||
label={cf.name}
|
||||
type={custom_field_input_type(cf.value_type)}
|
||||
/>
|
||||
</.inputs_for>
|
||||
<input
|
||||
type="hidden"
|
||||
name={f_cfv[:custom_field_id].name}
|
||||
value={f_cfv[:custom_field_id].value}
|
||||
/>
|
||||
</div>
|
||||
<% end %>
|
||||
</.inputs_for>
|
||||
<% end %>
|
||||
</div>
|
||||
</.form_section>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<%!-- Payment Data Section (Mockup) --%>
|
||||
<div class="max-w-xl">
|
||||
<.form_section title={gettext("Payment Data")}>
|
||||
<div role="alert" class="alert alert-info mb-4">
|
||||
<.icon name="hero-information-circle" class="size-5" />
|
||||
<span>{gettext("This data is for demonstration purposes only (mockup).")}</span>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-8">
|
||||
<div class="w-24">
|
||||
<label for="mock-contribution" class="label text-sm font-medium">
|
||||
{gettext("Contribution")}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="mock-contribution"
|
||||
value="72 €"
|
||||
disabled
|
||||
class="input input-bordered w-full bg-base-200"
|
||||
/>
|
||||
</div>
|
||||
<div class="w-40">
|
||||
<label class="label text-sm font-medium">{gettext("Payment Cycle")}</label>
|
||||
<div class="flex gap-3 mt-2">
|
||||
<label class="flex items-center gap-1 cursor-not-allowed opacity-60">
|
||||
<input type="radio" name="mock_cycle" checked disabled class="radio radio-sm" />
|
||||
<span class="text-sm">{gettext("monthly")}</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-1 cursor-not-allowed opacity-60">
|
||||
<input type="radio" name="mock_cycle" disabled class="radio radio-sm" />
|
||||
<span class="text-sm">{gettext("yearly")}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="w-24 flex items-end">
|
||||
<.input field={@form[:paid]} label={gettext("Paid")} type="checkbox" />
|
||||
</div>
|
||||
</div>
|
||||
</.form_section>
|
||||
</div>
|
||||
|
||||
<%!-- Bottom Action Buttons --%>
|
||||
<div class="flex justify-end gap-4 mt-6">
|
||||
<.button navigate={return_path(@return_to, @member)} type="button">
|
||||
{gettext("Cancel")}
|
||||
</.button>
|
||||
<.button phx-disable-with={gettext("Saving...")} variant="primary" type="submit">
|
||||
{gettext("Save Member")}
|
||||
</.button>
|
||||
</div>
|
||||
</.form>
|
||||
</Layouts.app>
|
||||
"""
|
||||
|
|
@ -107,8 +238,8 @@ defmodule MvWeb.MemberLive.Form do
|
|||
id -> Ash.get!(Mv.Membership.Member, id)
|
||||
end
|
||||
|
||||
action = if is_nil(member), do: "New", else: "Edit"
|
||||
page_title = action <> " " <> "Member"
|
||||
page_title =
|
||||
if is_nil(member), do: gettext("Create Member"), else: gettext("Edit Member")
|
||||
|
||||
{:ok,
|
||||
socket
|
||||
|
|
@ -214,5 +345,37 @@ defmodule MvWeb.MemberLive.Form do
|
|||
end
|
||||
|
||||
defp return_path("index", _member), do: ~p"/members"
|
||||
defp return_path("show", nil), do: ~p"/members"
|
||||
defp return_path("show", member), do: ~p"/members/#{member.id}"
|
||||
|
||||
# -----------------------------------------------------------------
|
||||
# Helper Components
|
||||
# -----------------------------------------------------------------
|
||||
|
||||
# Renders a form section box with border and title.
|
||||
attr :title, :string, required: true
|
||||
slot :inner_block, required: true
|
||||
|
||||
defp form_section(assigns) do
|
||||
~H"""
|
||||
<section class="mb-6">
|
||||
<h2 class="text-lg font-semibold mb-3">{@title}</h2>
|
||||
<div class="border border-base-300 rounded-lg p-4 bg-base-100">
|
||||
{render_slot(@inner_block)}
|
||||
</div>
|
||||
</section>
|
||||
"""
|
||||
end
|
||||
|
||||
# -----------------------------------------------------------------
|
||||
# Helper Functions for Custom Fields
|
||||
# -----------------------------------------------------------------
|
||||
|
||||
# Returns input type for custom field based on value type
|
||||
defp custom_field_input_type(:string), do: "text"
|
||||
defp custom_field_input_type(:integer), do: "number"
|
||||
defp custom_field_input_type(:boolean), do: "checkbox"
|
||||
defp custom_field_input_type(:date), do: "date"
|
||||
defp custom_field_input_type(:email), do: "email"
|
||||
defp custom_field_input_type(_), do: "text"
|
||||
end
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -2,23 +2,64 @@
|
|||
<.header>
|
||||
{gettext("Members")}
|
||||
<:actions>
|
||||
<.button
|
||||
:if={Enum.any?(@members, &MapSet.member?(@selected_members, &1.id))}
|
||||
id="copy-emails-btn"
|
||||
phx-hook="CopyToClipboard"
|
||||
phx-click="copy_emails"
|
||||
aria-label={gettext("Copy email addresses of selected members")}
|
||||
>
|
||||
<.icon name="hero-clipboard-document" />
|
||||
{gettext("Copy emails")} ({Enum.count(@members, &MapSet.member?(@selected_members, &1.id))})
|
||||
</.button>
|
||||
<.button
|
||||
:if={Enum.any?(@members, &MapSet.member?(@selected_members, &1.id))}
|
||||
href={
|
||||
"mailto:?bcc=" <>
|
||||
(MvWeb.MemberLive.Index.format_selected_member_emails(@members, @selected_members)
|
||||
|> Enum.join(", ")
|
||||
|> URI.encode())
|
||||
}
|
||||
aria-label={gettext("Open email program with BCC recipients")}
|
||||
>
|
||||
<.icon name="hero-envelope" />
|
||||
{gettext("Open in email program")}
|
||||
</.button>
|
||||
<.button variant="primary" navigate={~p"/members/new"}>
|
||||
<.icon name="hero-plus" /> {gettext("New Member")}
|
||||
</.button>
|
||||
</:actions>
|
||||
</.header>
|
||||
|
||||
<.live_component
|
||||
module={MvWeb.Components.SearchBarComponent}
|
||||
id="search-bar"
|
||||
query={@query}
|
||||
placeholder={gettext("Search...")}
|
||||
/>
|
||||
<div class="flex flex-wrap gap-4 items-center">
|
||||
<.live_component
|
||||
module={MvWeb.Components.SearchBarComponent}
|
||||
id="search-bar"
|
||||
query={@query}
|
||||
placeholder={gettext("Search...")}
|
||||
/>
|
||||
<.live_component
|
||||
module={MvWeb.Components.PaymentFilterComponent}
|
||||
id="payment-filter"
|
||||
paid_filter={@paid_filter}
|
||||
member_count={length(@members)}
|
||||
/>
|
||||
<.live_component
|
||||
module={MvWeb.Components.FieldVisibilityDropdownComponent}
|
||||
id="field-visibility-dropdown"
|
||||
all_fields={@all_available_fields}
|
||||
custom_fields={@all_custom_fields}
|
||||
selected_fields={@user_field_selection}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<.table
|
||||
id="members"
|
||||
rows={@members}
|
||||
row_click={fn member -> JS.navigate(~p"/members/#{member}") end}
|
||||
dynamic_cols={@dynamic_cols}
|
||||
sort_field={@sort_field}
|
||||
sort_order={@sort_order}
|
||||
>
|
||||
|
||||
<!-- <:col :let={member} label="Id">{member.id}</:col> -->
|
||||
|
|
@ -30,7 +71,7 @@
|
|||
type="checkbox"
|
||||
name="select_all"
|
||||
phx-click="select_all"
|
||||
checked={Enum.sort(@selected_members) == Enum.map(@members, & &1.id) |> Enum.sort()}
|
||||
checked={MapSet.equal?(@selected_members, @members |> Enum.map(& &1.id) |> MapSet.new())}
|
||||
aria-label={gettext("Select all members")}
|
||||
role="checkbox"
|
||||
/>
|
||||
|
|
@ -42,7 +83,7 @@
|
|||
name={member.id}
|
||||
phx-click="select_member"
|
||||
phx-value-id={member.id}
|
||||
checked={member.id in @selected_members}
|
||||
checked={MapSet.member?(@selected_members, member.id)}
|
||||
phx-capture-click
|
||||
phx-stop-propagation
|
||||
aria-label={gettext("Select member")}
|
||||
|
|
@ -51,6 +92,7 @@
|
|||
</:col>
|
||||
<:col
|
||||
:let={member}
|
||||
:if={:first_name in @member_fields_visible}
|
||||
label={
|
||||
~H"""
|
||||
<.live_component
|
||||
|
|
@ -64,10 +106,29 @@
|
|||
"""
|
||||
}
|
||||
>
|
||||
{member.first_name} {member.last_name}
|
||||
{member.first_name}
|
||||
</:col>
|
||||
<:col
|
||||
:let={member}
|
||||
:if={:last_name in @member_fields_visible}
|
||||
label={
|
||||
~H"""
|
||||
<.live_component
|
||||
module={MvWeb.Components.SortHeaderComponent}
|
||||
id={:sort_last_name}
|
||||
field={:last_name}
|
||||
label={gettext("Last name")}
|
||||
sort_field={@sort_field}
|
||||
sort_order={@sort_order}
|
||||
/>
|
||||
"""
|
||||
}
|
||||
>
|
||||
{member.last_name}
|
||||
</:col>
|
||||
<:col
|
||||
:let={member}
|
||||
:if={:email in @member_fields_visible}
|
||||
label={
|
||||
~H"""
|
||||
<.live_component
|
||||
|
|
@ -85,6 +146,7 @@
|
|||
</:col>
|
||||
<:col
|
||||
:let={member}
|
||||
:if={:street in @member_fields_visible}
|
||||
label={
|
||||
~H"""
|
||||
<.live_component
|
||||
|
|
@ -102,6 +164,7 @@
|
|||
</:col>
|
||||
<:col
|
||||
:let={member}
|
||||
:if={:house_number in @member_fields_visible}
|
||||
label={
|
||||
~H"""
|
||||
<.live_component
|
||||
|
|
@ -119,6 +182,7 @@
|
|||
</:col>
|
||||
<:col
|
||||
:let={member}
|
||||
:if={:postal_code in @member_fields_visible}
|
||||
label={
|
||||
~H"""
|
||||
<.live_component
|
||||
|
|
@ -136,6 +200,7 @@
|
|||
</:col>
|
||||
<:col
|
||||
:let={member}
|
||||
:if={:city in @member_fields_visible}
|
||||
label={
|
||||
~H"""
|
||||
<.live_component
|
||||
|
|
@ -153,6 +218,7 @@
|
|||
</:col>
|
||||
<:col
|
||||
:let={member}
|
||||
:if={:phone_number in @member_fields_visible}
|
||||
label={
|
||||
~H"""
|
||||
<.live_component
|
||||
|
|
@ -170,6 +236,7 @@
|
|||
</:col>
|
||||
<:col
|
||||
:let={member}
|
||||
:if={:join_date in @member_fields_visible}
|
||||
label={
|
||||
~H"""
|
||||
<.live_component
|
||||
|
|
@ -183,9 +250,16 @@
|
|||
"""
|
||||
}
|
||||
>
|
||||
{member.join_date}
|
||||
{MvWeb.MemberLive.Index.format_date(member.join_date)}
|
||||
</:col>
|
||||
<:col :let={member} :if={:paid in @member_fields_visible} label={gettext("Paid")}>
|
||||
<span class={[
|
||||
"badge",
|
||||
if(member.paid == true, do: "badge-success", else: "badge-error")
|
||||
]}>
|
||||
{if member.paid == true, do: gettext("Yes"), else: gettext("No")}
|
||||
</span>
|
||||
</:col>
|
||||
|
||||
<:action :let={member}>
|
||||
<div class="sr-only">
|
||||
<.link navigate={~p"/members/#{member}"}>{gettext("Show")}</.link>
|
||||
|
|
|
|||
231
lib/mv_web/live/member_live/index/field_selection.ex
Normal file
231
lib/mv_web/live/member_live/index/field_selection.ex
Normal file
|
|
@ -0,0 +1,231 @@
|
|||
defmodule MvWeb.MemberLive.Index.FieldSelection do
|
||||
@moduledoc """
|
||||
Handles user-specific field selection persistence and URL parameter parsing.
|
||||
|
||||
This module manages:
|
||||
- Reading/writing field selection from cookies (persistent storage)
|
||||
- Reading/writing field selection from session (temporary storage)
|
||||
- Parsing field selection from URL parameters
|
||||
- Merging multiple sources with priority: URL > Session > Cookie
|
||||
|
||||
## Data Format
|
||||
|
||||
Field selection is stored as a map:
|
||||
```elixir
|
||||
%{
|
||||
"first_name" => true,
|
||||
"email" => true,
|
||||
"street" => false,
|
||||
"custom_field_abc-123" => true
|
||||
}
|
||||
```
|
||||
|
||||
## Cookie/Session Format
|
||||
|
||||
Stored as JSON string: `{"first_name":true,"email":true}`
|
||||
|
||||
## URL Format
|
||||
|
||||
Comma-separated list: `?fields=first_name,email,custom_field_abc-123`
|
||||
"""
|
||||
|
||||
@cookie_name "member_field_selection"
|
||||
@cookie_max_age 365 * 24 * 60 * 60
|
||||
@session_key "member_field_selection"
|
||||
|
||||
@doc """
|
||||
Reads field selection from session.
|
||||
|
||||
Returns a map of field names (strings) to boolean visibility values.
|
||||
Returns empty map if no selection is stored.
|
||||
"""
|
||||
@spec get_from_session(map()) :: %{String.t() => boolean()}
|
||||
def get_from_session(session) when is_map(session) do
|
||||
case Map.get(session, @session_key) do
|
||||
nil -> %{}
|
||||
json_string when is_binary(json_string) -> parse_json(json_string)
|
||||
_ -> %{}
|
||||
end
|
||||
end
|
||||
|
||||
def get_from_session(_), do: %{}
|
||||
|
||||
@doc """
|
||||
Saves field selection to session.
|
||||
|
||||
Converts the map to JSON string and stores it in the session.
|
||||
"""
|
||||
@spec save_to_session(map(), %{String.t() => boolean()}) :: map()
|
||||
def save_to_session(session, selection) when is_map(selection) do
|
||||
json_string = Jason.encode!(selection)
|
||||
Map.put(session, @session_key, json_string)
|
||||
end
|
||||
|
||||
def save_to_session(session, _), do: session
|
||||
|
||||
@doc """
|
||||
Reads field selection from cookie.
|
||||
|
||||
Returns a map of field names (strings) to boolean visibility values.
|
||||
Returns empty map if no cookie is present.
|
||||
|
||||
Note: This function parses the raw Cookie header. In LiveView, cookies
|
||||
are typically accessed via get_connect_info.
|
||||
"""
|
||||
@spec get_from_cookie(Plug.Conn.t()) :: %{String.t() => boolean()}
|
||||
def get_from_cookie(conn) do
|
||||
# get_req_header always returns a list ([] if no header, [value] if present)
|
||||
case Plug.Conn.get_req_header(conn, "cookie") do
|
||||
[] ->
|
||||
%{}
|
||||
|
||||
[cookie_header | _rest] ->
|
||||
cookies = parse_cookie_header(cookie_header)
|
||||
|
||||
case Map.get(cookies, @cookie_name) do
|
||||
nil -> %{}
|
||||
json_string when is_binary(json_string) -> parse_json(json_string)
|
||||
_ -> %{}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Parses cookie header string into a map
|
||||
defp parse_cookie_header(cookie_header) when is_binary(cookie_header) do
|
||||
cookie_header
|
||||
|> String.split(";")
|
||||
|> Enum.map(&String.trim/1)
|
||||
|> Enum.map(&String.split(&1, "=", parts: 2))
|
||||
|> Enum.reduce(%{}, fn
|
||||
[key, value], acc -> Map.put(acc, key, URI.decode(value))
|
||||
[key], acc -> Map.put(acc, key, "")
|
||||
_, acc -> acc
|
||||
end)
|
||||
end
|
||||
|
||||
defp parse_cookie_header(_), do: %{}
|
||||
|
||||
@doc """
|
||||
Saves field selection to cookie.
|
||||
|
||||
Sets a persistent cookie with the field selection as JSON.
|
||||
"""
|
||||
@spec save_to_cookie(Plug.Conn.t(), %{String.t() => boolean()}) :: Plug.Conn.t()
|
||||
def save_to_cookie(conn, selection) when is_map(selection) do
|
||||
json_string = Jason.encode!(selection)
|
||||
secure = Application.get_env(:mv, :use_secure_cookies, false)
|
||||
|
||||
Plug.Conn.put_resp_cookie(conn, @cookie_name, json_string,
|
||||
max_age: @cookie_max_age,
|
||||
same_site: "Lax",
|
||||
http_only: true,
|
||||
secure: secure
|
||||
)
|
||||
end
|
||||
|
||||
def save_to_cookie(conn, _), do: conn
|
||||
|
||||
@doc """
|
||||
Parses field selection from URL parameters.
|
||||
|
||||
Expects a comma-separated list of field names in the `fields` parameter.
|
||||
All fields in the list are set to `true` (visible).
|
||||
|
||||
## Examples
|
||||
|
||||
iex> parse_from_url(%{"fields" => "first_name,email"})
|
||||
%{"first_name" => true, "email" => true}
|
||||
|
||||
iex> parse_from_url(%{"fields" => "custom_field_abc-123"})
|
||||
%{"custom_field_abc-123" => true}
|
||||
|
||||
iex> parse_from_url(%{})
|
||||
%{}
|
||||
"""
|
||||
@spec parse_from_url(map()) :: %{String.t() => boolean()}
|
||||
def parse_from_url(params) when is_map(params) do
|
||||
case Map.get(params, "fields") do
|
||||
nil -> %{}
|
||||
"" -> %{}
|
||||
fields_string when is_binary(fields_string) -> parse_fields_string(fields_string)
|
||||
_ -> %{}
|
||||
end
|
||||
end
|
||||
|
||||
def parse_from_url(_), do: %{}
|
||||
|
||||
@doc """
|
||||
Merges multiple field selection sources with priority.
|
||||
|
||||
Priority order (highest to lowest):
|
||||
1. URL parameters
|
||||
2. Session
|
||||
3. Cookie
|
||||
|
||||
Later sources override earlier ones for the same field.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> merge_sources(%{"first_name" => true}, %{"email" => true}, %{"street" => true})
|
||||
%{"first_name" => true, "email" => true, "street" => true}
|
||||
|
||||
iex> merge_sources(%{"first_name" => false}, %{"first_name" => true}, %{})
|
||||
%{"first_name" => false} # URL has priority
|
||||
"""
|
||||
@spec merge_sources(
|
||||
%{String.t() => boolean()},
|
||||
%{String.t() => boolean()},
|
||||
%{String.t() => boolean()}
|
||||
) :: %{String.t() => boolean()}
|
||||
def merge_sources(url_selection, session_selection, cookie_selection) do
|
||||
%{}
|
||||
|> Map.merge(cookie_selection)
|
||||
|> Map.merge(session_selection)
|
||||
|> Map.merge(url_selection)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Converts field selection map to URL parameter string.
|
||||
|
||||
Returns a comma-separated string of visible fields (where value is `true`).
|
||||
|
||||
## Examples
|
||||
|
||||
iex> to_url_param(%{"first_name" => true, "email" => true, "street" => false})
|
||||
"first_name,email"
|
||||
"""
|
||||
@spec to_url_param(%{String.t() => boolean()}) :: String.t()
|
||||
def to_url_param(selection) when is_map(selection) do
|
||||
selection
|
||||
|> Enum.filter(fn {_field, visible} -> visible end)
|
||||
|> Enum.map_join(",", fn {field, _visible} -> field end)
|
||||
end
|
||||
|
||||
def to_url_param(_), do: ""
|
||||
|
||||
# Parses a JSON string into a map, handling errors gracefully
|
||||
defp parse_json(json_string) when is_binary(json_string) do
|
||||
case Jason.decode(json_string) do
|
||||
{:ok, decoded} when is_map(decoded) ->
|
||||
# Ensure all values are booleans
|
||||
Enum.reduce(decoded, %{}, fn
|
||||
{key, value}, acc when is_boolean(value) -> Map.put(acc, key, value)
|
||||
{key, _value}, acc -> Map.put(acc, key, true)
|
||||
end)
|
||||
|
||||
_ ->
|
||||
%{}
|
||||
end
|
||||
end
|
||||
|
||||
defp parse_json(_), do: %{}
|
||||
|
||||
# Parses a comma-separated string of field names
|
||||
defp parse_fields_string(fields_string) do
|
||||
fields_string
|
||||
|> String.split(",")
|
||||
|> Enum.map(&String.trim/1)
|
||||
|> Enum.filter(&(&1 != ""))
|
||||
|> Enum.reduce(%{}, fn field, acc -> Map.put(acc, field, true) end)
|
||||
end
|
||||
end
|
||||
239
lib/mv_web/live/member_live/index/field_visibility.ex
Normal file
239
lib/mv_web/live/member_live/index/field_visibility.ex
Normal file
|
|
@ -0,0 +1,239 @@
|
|||
defmodule MvWeb.MemberLive.Index.FieldVisibility do
|
||||
@moduledoc """
|
||||
Manages field visibility by merging user-specific selection with global settings.
|
||||
|
||||
This module handles:
|
||||
- Getting all available fields (member fields + custom fields)
|
||||
- Merging user selection with global settings (user selection takes priority)
|
||||
- Falling back to global settings when no user selection exists
|
||||
- Converting between different field name formats (atoms vs strings)
|
||||
|
||||
## Field Naming Convention
|
||||
|
||||
- **Member Fields**: Atoms (e.g., `:first_name`, `:email`)
|
||||
- **Custom Fields**: Strings with format `"custom_field_<id>"` (e.g., `"custom_field_abc-123"`)
|
||||
|
||||
## Priority Order
|
||||
|
||||
1. User-specific selection (from URL/Session/Cookie)
|
||||
2. Global settings (from database)
|
||||
3. Default (all fields visible)
|
||||
"""
|
||||
|
||||
@doc """
|
||||
Gets all available fields for selection.
|
||||
|
||||
Returns a list of field identifiers:
|
||||
- Member fields as atoms (e.g., `:first_name`, `:email`)
|
||||
- Custom fields as strings (e.g., `"custom_field_abc-123"`)
|
||||
|
||||
## Parameters
|
||||
|
||||
- `custom_fields` - List of CustomField resources that are available
|
||||
|
||||
## Returns
|
||||
|
||||
List of field identifiers (atoms and strings)
|
||||
"""
|
||||
@spec get_all_available_fields([struct()]) :: [atom() | String.t()]
|
||||
def get_all_available_fields(custom_fields) do
|
||||
member_fields = Mv.Constants.member_fields()
|
||||
custom_field_names = Enum.map(custom_fields, &"custom_field_#{&1.id}")
|
||||
|
||||
member_fields ++ custom_field_names
|
||||
end
|
||||
|
||||
@doc """
|
||||
Merges user field selection with global settings.
|
||||
|
||||
User selection takes priority over global settings. If a field is not in the
|
||||
user selection, the global setting is used. If a field is not in global settings,
|
||||
it defaults to `true` (visible).
|
||||
|
||||
## Parameters
|
||||
|
||||
- `user_selection` - Map of field names (strings) to boolean visibility
|
||||
- `global_settings` - Settings struct with `member_field_visibility` field
|
||||
- `custom_fields` - List of CustomField resources
|
||||
|
||||
## Returns
|
||||
|
||||
Map of field names (strings) to boolean visibility values
|
||||
|
||||
## Examples
|
||||
|
||||
iex> user_selection = %{"first_name" => false}
|
||||
iex> settings = %{member_field_visibility: %{first_name: true, email: true}}
|
||||
iex> merge_with_global_settings(user_selection, settings, [])
|
||||
%{"first_name" => false, "email" => true} # User selection overrides global
|
||||
"""
|
||||
@spec merge_with_global_settings(
|
||||
%{String.t() => boolean()},
|
||||
map(),
|
||||
[struct()]
|
||||
) :: %{String.t() => boolean()}
|
||||
def merge_with_global_settings(user_selection, global_settings, custom_fields) do
|
||||
all_fields = get_all_available_fields(custom_fields)
|
||||
global_visibility = get_global_visibility_map(global_settings, custom_fields)
|
||||
|
||||
Enum.reduce(all_fields, %{}, fn field, acc ->
|
||||
field_string = field_to_string(field)
|
||||
|
||||
visibility =
|
||||
case Map.get(user_selection, field_string) do
|
||||
nil -> Map.get(global_visibility, field_string, true)
|
||||
user_value -> user_value
|
||||
end
|
||||
|
||||
Map.put(acc, field_string, visibility)
|
||||
end)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the list of visible fields from a field selection map.
|
||||
|
||||
Returns only fields where visibility is `true`.
|
||||
|
||||
## Parameters
|
||||
|
||||
- `field_selection` - Map of field names to boolean visibility
|
||||
|
||||
## Returns
|
||||
|
||||
List of field identifiers (atoms for member fields, strings for custom fields)
|
||||
|
||||
## Examples
|
||||
|
||||
iex> selection = %{"first_name" => true, "email" => false, "street" => true}
|
||||
iex> get_visible_fields(selection)
|
||||
[:first_name, :street]
|
||||
"""
|
||||
@spec get_visible_fields(%{String.t() => boolean()}) :: [atom() | String.t()]
|
||||
def get_visible_fields(field_selection) when is_map(field_selection) do
|
||||
field_selection
|
||||
|> Enum.filter(fn {_field, visible} -> visible end)
|
||||
|> Enum.map(fn {field_string, _visible} -> to_field_identifier(field_string) end)
|
||||
end
|
||||
|
||||
def get_visible_fields(_), do: []
|
||||
|
||||
@doc """
|
||||
Gets visible member fields from field selection.
|
||||
|
||||
Returns only member fields (atoms) that are visible.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> selection = %{"first_name" => true, "email" => true, "custom_field_123" => true}
|
||||
iex> get_visible_member_fields(selection)
|
||||
[:first_name, :email]
|
||||
"""
|
||||
@spec get_visible_member_fields(%{String.t() => boolean()}) :: [atom()]
|
||||
def get_visible_member_fields(field_selection) when is_map(field_selection) do
|
||||
member_fields = Mv.Constants.member_fields()
|
||||
|
||||
field_selection
|
||||
|> Enum.filter(fn {field_string, visible} ->
|
||||
field_atom = to_field_identifier(field_string)
|
||||
visible && field_atom in member_fields
|
||||
end)
|
||||
|> Enum.map(fn {field_string, _visible} -> to_field_identifier(field_string) end)
|
||||
end
|
||||
|
||||
def get_visible_member_fields(_), do: []
|
||||
|
||||
@doc """
|
||||
Gets visible custom fields from field selection.
|
||||
|
||||
Returns only custom field identifiers (strings) that are visible.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> selection = %{"first_name" => true, "custom_field_123" => true, "custom_field_456" => false}
|
||||
iex> get_visible_custom_fields(selection)
|
||||
["custom_field_123"]
|
||||
"""
|
||||
@spec get_visible_custom_fields(%{String.t() => boolean()}) :: [String.t()]
|
||||
def get_visible_custom_fields(field_selection) when is_map(field_selection) do
|
||||
prefix = Mv.Constants.custom_field_prefix()
|
||||
|
||||
field_selection
|
||||
|> Enum.filter(fn {field_string, visible} ->
|
||||
visible && String.starts_with?(field_string, prefix)
|
||||
end)
|
||||
|> Enum.map(fn {field_string, _visible} -> field_string end)
|
||||
end
|
||||
|
||||
def get_visible_custom_fields(_), do: []
|
||||
|
||||
# Gets global visibility map from settings
|
||||
defp get_global_visibility_map(settings, custom_fields) do
|
||||
member_visibility = get_member_field_visibility_from_settings(settings)
|
||||
custom_field_visibility = get_custom_field_visibility(custom_fields)
|
||||
|
||||
Map.merge(member_visibility, custom_field_visibility)
|
||||
end
|
||||
|
||||
# Gets member field visibility from settings
|
||||
defp get_member_field_visibility_from_settings(settings) do
|
||||
visibility_config =
|
||||
normalize_visibility_config(Map.get(settings, :member_field_visibility, %{}))
|
||||
|
||||
member_fields = Mv.Constants.member_fields()
|
||||
|
||||
Enum.reduce(member_fields, %{}, fn field, acc ->
|
||||
field_string = Atom.to_string(field)
|
||||
show_in_overview = Map.get(visibility_config, field, true)
|
||||
Map.put(acc, field_string, show_in_overview)
|
||||
end)
|
||||
end
|
||||
|
||||
# Gets custom field visibility (all custom fields with show_in_overview=true are visible)
|
||||
defp get_custom_field_visibility(custom_fields) do
|
||||
prefix = Mv.Constants.custom_field_prefix()
|
||||
|
||||
Enum.reduce(custom_fields, %{}, fn custom_field, acc ->
|
||||
field_string = "#{prefix}#{custom_field.id}"
|
||||
visible = Map.get(custom_field, :show_in_overview, true)
|
||||
Map.put(acc, field_string, visible)
|
||||
end)
|
||||
end
|
||||
|
||||
# Normalizes visibility config map keys from strings to atoms
|
||||
defp normalize_visibility_config(config) when is_map(config) do
|
||||
Enum.reduce(config, %{}, fn
|
||||
{key, value}, acc when is_atom(key) ->
|
||||
Map.put(acc, key, value)
|
||||
|
||||
{key, value}, acc when is_binary(key) ->
|
||||
try do
|
||||
atom_key = String.to_existing_atom(key)
|
||||
Map.put(acc, atom_key, value)
|
||||
rescue
|
||||
ArgumentError -> acc
|
||||
end
|
||||
|
||||
_, acc ->
|
||||
acc
|
||||
end)
|
||||
end
|
||||
|
||||
defp normalize_visibility_config(_), do: %{}
|
||||
|
||||
# Converts field string to atom (for member fields) or keeps as string (for custom fields)
|
||||
defp to_field_identifier(field_string) when is_binary(field_string) do
|
||||
if String.starts_with?(field_string, Mv.Constants.custom_field_prefix()) do
|
||||
field_string
|
||||
else
|
||||
try do
|
||||
String.to_existing_atom(field_string)
|
||||
rescue
|
||||
ArgumentError -> field_string
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Converts field identifier to string
|
||||
defp field_to_string(field) when is_atom(field), do: Atom.to_string(field)
|
||||
defp field_to_string(field) when is_binary(field), do: field
|
||||
end
|
||||
75
lib/mv_web/live/member_live/index/formatter.ex
Normal file
75
lib/mv_web/live/member_live/index/formatter.ex
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
defmodule MvWeb.MemberLive.Index.Formatter do
|
||||
@moduledoc """
|
||||
Formats custom field values for display in the member overview table.
|
||||
|
||||
Handles different value types (string, integer, boolean, date, email) and
|
||||
formats them appropriately for display in the UI.
|
||||
"""
|
||||
use Gettext, backend: MvWeb.Gettext
|
||||
alias MvWeb.Helpers.DateFormatter
|
||||
|
||||
@doc """
|
||||
Formats a custom field value for display.
|
||||
|
||||
Handles different input formats:
|
||||
- `nil` - Returns empty string
|
||||
- `%Ash.Union{}` - Extracts value and type from union type
|
||||
- Map (JSONB format) - Extracts type and value from map keys
|
||||
- Direct value - Uses custom_field.value_type to determine format
|
||||
|
||||
## Examples
|
||||
|
||||
iex> format_custom_field_value(nil, %CustomField{value_type: :string})
|
||||
""
|
||||
|
||||
iex> format_custom_field_value("test", %CustomField{value_type: :string})
|
||||
"test"
|
||||
|
||||
iex> format_custom_field_value(true, %CustomField{value_type: :boolean})
|
||||
"Yes"
|
||||
"""
|
||||
def format_custom_field_value(nil, _custom_field), do: ""
|
||||
|
||||
def format_custom_field_value(%Ash.Union{value: value, type: type}, custom_field) do
|
||||
format_value_by_type(value, type, custom_field)
|
||||
end
|
||||
|
||||
def format_custom_field_value(value, custom_field) when is_map(value) do
|
||||
# Handle map format from JSONB
|
||||
type = Map.get(value, "type") || Map.get(value, "_union_type")
|
||||
val = Map.get(value, "value") || Map.get(value, "_union_value")
|
||||
format_value_by_type(val, type, custom_field)
|
||||
end
|
||||
|
||||
def format_custom_field_value(value, custom_field) do
|
||||
format_value_by_type(value, custom_field.value_type, custom_field)
|
||||
end
|
||||
|
||||
# Format value based on type
|
||||
|
||||
defp format_value_by_type(value, :string, _), do: to_string(value)
|
||||
|
||||
defp format_value_by_type(value, :integer, _), do: to_string(value)
|
||||
|
||||
defp format_value_by_type(value, type, _) when type in [:string, :email] and is_binary(value) do
|
||||
# Return empty string if value is empty
|
||||
if String.trim(value) == "", do: "", else: value
|
||||
end
|
||||
|
||||
defp format_value_by_type(value, :email, _), do: to_string(value)
|
||||
|
||||
defp format_value_by_type(value, :boolean, _) when value == true, do: gettext("Yes")
|
||||
defp format_value_by_type(value, :boolean, _) when value == false, do: gettext("No")
|
||||
defp format_value_by_type(value, :boolean, _), do: to_string(value)
|
||||
|
||||
defp format_value_by_type(%Date{} = date, :date, _), do: DateFormatter.format_date(date)
|
||||
|
||||
defp format_value_by_type(value, :date, _) when is_binary(value) do
|
||||
case Date.from_iso8601(value) do
|
||||
{:ok, date} -> DateFormatter.format_date(date)
|
||||
_ -> value
|
||||
end
|
||||
end
|
||||
|
||||
defp format_value_by_type(value, _type, _), do: to_string(value)
|
||||
end
|
||||
|
|
@ -3,19 +3,16 @@ defmodule MvWeb.MemberLive.Show do
|
|||
LiveView for displaying a single member's details.
|
||||
|
||||
## Features
|
||||
- Display all member information (personal, contact, address)
|
||||
- Show linked user account (if exists)
|
||||
- Display custom field values
|
||||
- Display all member information in grouped sections
|
||||
- Tab navigation for future features (Payments)
|
||||
- Show custom field values with type-based formatting
|
||||
- Navigate to edit form
|
||||
- Return to member list
|
||||
|
||||
## Displayed Information
|
||||
- Basic: name, email, dates (birth, join, exit)
|
||||
- Contact: phone number
|
||||
- Address: street, house number, postal code, city
|
||||
- Status: paid flag
|
||||
- Relationships: linked user account
|
||||
- Custom: dynamic custom field values from CustomFields
|
||||
## Sections
|
||||
- Personal Data: Name, address, contact information, membership dates, notes
|
||||
- Custom Fields: Dynamic fields in uniform grid layout (sorted by name)
|
||||
- Payment Data: Mockup section with placeholder data
|
||||
|
||||
## Navigation
|
||||
- Back to member list
|
||||
|
|
@ -28,67 +25,150 @@ defmodule MvWeb.MemberLive.Show do
|
|||
def render(assigns) do
|
||||
~H"""
|
||||
<Layouts.app flash={@flash} current_user={@current_user}>
|
||||
<.header>
|
||||
{@member.first_name} {@member.last_name}
|
||||
<:subtitle>{gettext("This is a member record from your database.")}</:subtitle>
|
||||
<%!-- Header with Back button, Name, and Edit button --%>
|
||||
<div class="flex items-center justify-between gap-4 pb-4">
|
||||
<.button navigate={~p"/members"} aria-label={gettext("Back to members list")}>
|
||||
<.icon name="hero-arrow-left" class="size-4" />
|
||||
{gettext("Back")}
|
||||
</.button>
|
||||
|
||||
<:actions>
|
||||
<.button navigate={~p"/members"} aria-label={gettext("Back to members list")}>
|
||||
<.icon name="hero-arrow-left" />
|
||||
<span class="sr-only">{gettext("Back to members list")}</span>
|
||||
</.button>
|
||||
<.button variant="primary" navigate={~p"/members/#{@member}/edit?return_to=show"}>
|
||||
<.icon name="hero-pencil-square" /> {gettext("Edit Member")}
|
||||
</.button>
|
||||
</:actions>
|
||||
</.header>
|
||||
<h1 class="text-2xl font-bold text-center flex-1">
|
||||
{@member.first_name} {@member.last_name}
|
||||
</h1>
|
||||
|
||||
<.list>
|
||||
<:item title={gettext("Id")}>{@member.id}</:item>
|
||||
<:item title={gettext("First Name")}>{@member.first_name}</:item>
|
||||
<:item title={gettext("Last Name")}>{@member.last_name}</:item>
|
||||
<:item title={gettext("Email")}>{@member.email}</:item>
|
||||
<:item title={gettext("Birth Date")}>{@member.birth_date}</:item>
|
||||
<:item title={gettext("Paid")}>
|
||||
{if @member.paid, do: gettext("Yes"), else: gettext("No")}
|
||||
</:item>
|
||||
<:item title={gettext("Phone Number")}>{@member.phone_number}</:item>
|
||||
<:item title={gettext("Join Date")}>{@member.join_date}</:item>
|
||||
<:item title={gettext("Exit Date")}>{@member.exit_date}</:item>
|
||||
<:item title={gettext("Notes")}>{@member.notes}</:item>
|
||||
<:item title={gettext("City")}>{@member.city}</:item>
|
||||
<:item title={gettext("Street")}>{@member.street}</:item>
|
||||
<:item title={gettext("House Number")}>{@member.house_number}</:item>
|
||||
<:item title={gettext("Postal Code")}>{@member.postal_code}</:item>
|
||||
<:item title={gettext("Linked User")}>
|
||||
<%= if @member.user do %>
|
||||
<.link
|
||||
navigate={~p"/users/#{@member.user}"}
|
||||
class="text-blue-600 hover:text-blue-800 underline"
|
||||
>
|
||||
<.icon name="hero-user" class="h-4 w-4 inline mr-1" />
|
||||
{@member.user.email}
|
||||
</.link>
|
||||
<% else %>
|
||||
<span class="text-gray-500 italic">{gettext("No user linked")}</span>
|
||||
<% end %>
|
||||
</:item>
|
||||
</.list>
|
||||
<.button variant="primary" navigate={~p"/members/#{@member}/edit?return_to=show"}>
|
||||
{gettext("Edit Member")}
|
||||
</.button>
|
||||
</div>
|
||||
|
||||
<h3 class="mt-8 mb-2 text-lg font-semibold">{gettext("Custom Field Values")}</h3>
|
||||
<.generic_list items={
|
||||
Enum.map(@member.custom_field_values, fn cfv ->
|
||||
{
|
||||
# name
|
||||
cfv.custom_field && cfv.custom_field.name,
|
||||
# value
|
||||
case cfv.value do
|
||||
%{value: v} -> v
|
||||
v -> v
|
||||
end
|
||||
}
|
||||
end)
|
||||
} />
|
||||
<%!-- Tab Navigation --%>
|
||||
<div role="tablist" class="tabs tabs-bordered mb-6">
|
||||
<button role="tab" class="tab tab-active" aria-selected="true">
|
||||
<.icon name="hero-identification" class="size-4 mr-2" />
|
||||
{gettext("Contact Data")}
|
||||
</button>
|
||||
<button role="tab" class="tab" disabled aria-disabled="true" title={gettext("Coming soon")}>
|
||||
<.icon name="hero-credit-card" class="size-4 mr-2" />
|
||||
{gettext("Payments")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<%!-- Personal Data and Custom Fields Row --%>
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
|
||||
<%!-- Personal Data Section --%>
|
||||
<div>
|
||||
<.section_box title={gettext("Personal Data")}>
|
||||
<div class="space-y-4">
|
||||
<%!-- Name Row --%>
|
||||
<div class="flex gap-6">
|
||||
<.data_field label={gettext("First Name")} value={@member.first_name} class="w-48" />
|
||||
<.data_field label={gettext("Last Name")} value={@member.last_name} class="w-48" />
|
||||
</div>
|
||||
|
||||
<%!-- Address --%>
|
||||
<div>
|
||||
<.data_field label={gettext("Address")} value={format_address(@member)} />
|
||||
</div>
|
||||
|
||||
<%!-- Email --%>
|
||||
<div>
|
||||
<.data_field label={gettext("Email")}>
|
||||
<a
|
||||
href={"mailto:#{MvWeb.MemberLive.Index.format_member_email(@member)}"}
|
||||
class="text-blue-700 hover:text-blue-800 underline"
|
||||
>
|
||||
{@member.email}
|
||||
</a>
|
||||
</.data_field>
|
||||
</div>
|
||||
|
||||
<%!-- Phone --%>
|
||||
<div>
|
||||
<.data_field label={gettext("Phone")} value={@member.phone_number} />
|
||||
</div>
|
||||
|
||||
<%!-- Membership Dates Row --%>
|
||||
<div class="flex gap-6">
|
||||
<.data_field
|
||||
label={gettext("Join Date")}
|
||||
value={format_date(@member.join_date)}
|
||||
class="w-28"
|
||||
/>
|
||||
<.data_field
|
||||
label={gettext("Exit Date")}
|
||||
value={format_date(@member.exit_date)}
|
||||
class="w-28"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<%!-- Linked User --%>
|
||||
<div>
|
||||
<.data_field label={gettext("Linked User")}>
|
||||
<%= if @member.user do %>
|
||||
<.link
|
||||
navigate={~p"/users/#{@member.user}"}
|
||||
class="text-blue-700 hover:text-blue-800 underline inline-flex items-center gap-1"
|
||||
>
|
||||
<.icon name="hero-user" class="size-4" />
|
||||
{@member.user.email}
|
||||
</.link>
|
||||
<% else %>
|
||||
<span class="text-base-content/70 italic">{gettext("No user linked")}</span>
|
||||
<% end %>
|
||||
</.data_field>
|
||||
</div>
|
||||
|
||||
<%!-- Notes --%>
|
||||
<%= if @member.notes && String.trim(@member.notes) != "" do %>
|
||||
<div>
|
||||
<.data_field label={gettext("Notes")}>
|
||||
<p class="whitespace-pre-wrap text-base-content/80">{@member.notes}</p>
|
||||
</.data_field>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</.section_box>
|
||||
</div>
|
||||
|
||||
<%!-- Custom Fields Section --%>
|
||||
<%= if Enum.any?(@member.custom_field_values) do %>
|
||||
<div>
|
||||
<.section_box title={gettext("Custom Fields")}>
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<%= for cfv <- sort_custom_field_values(@member.custom_field_values) do %>
|
||||
<% custom_field = cfv.custom_field %>
|
||||
<% value_type = custom_field && custom_field.value_type %>
|
||||
<.data_field label={custom_field && custom_field.name}>
|
||||
{format_custom_field_value(cfv.value, value_type)}
|
||||
</.data_field>
|
||||
<% end %>
|
||||
</div>
|
||||
</.section_box>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
|
||||
<%!-- Payment Data Section (Mockup) --%>
|
||||
<div class="max-w-xl">
|
||||
<.section_box title={gettext("Payment Data")}>
|
||||
<div role="alert" class="alert alert-info mb-4">
|
||||
<.icon name="hero-information-circle" class="size-5" />
|
||||
<span>{gettext("This data is for demonstration purposes only (mockup).")}</span>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-6">
|
||||
<.data_field label={gettext("Contribution")} value="72 €" class="w-24" />
|
||||
<.data_field label={gettext("Payment Cycle")} value={gettext("monthly")} class="w-28" />
|
||||
<.data_field label={gettext("Paid")} class="w-24">
|
||||
<%= if @member.paid do %>
|
||||
<span class="badge badge-success">{gettext("Paid")}</span>
|
||||
<% else %>
|
||||
<span class="badge badge-warning">{gettext("Pending")}</span>
|
||||
<% end %>
|
||||
</.data_field>
|
||||
</div>
|
||||
</.section_box>
|
||||
</div>
|
||||
</Layouts.app>
|
||||
"""
|
||||
end
|
||||
|
|
@ -115,4 +195,120 @@ defmodule MvWeb.MemberLive.Show do
|
|||
|
||||
defp page_title(:show), do: gettext("Show Member")
|
||||
defp page_title(:edit), do: gettext("Edit Member")
|
||||
|
||||
# -----------------------------------------------------------------
|
||||
# Helper Components
|
||||
# -----------------------------------------------------------------
|
||||
|
||||
# Renders a section box with border and title.
|
||||
attr :title, :string, required: true
|
||||
slot :inner_block, required: true
|
||||
|
||||
defp section_box(assigns) do
|
||||
~H"""
|
||||
<section class="mb-6">
|
||||
<h2 class="text-lg font-semibold mb-3">{@title}</h2>
|
||||
<div class="border border-base-300 rounded-lg p-4 bg-base-100">
|
||||
{render_slot(@inner_block)}
|
||||
</div>
|
||||
</section>
|
||||
"""
|
||||
end
|
||||
|
||||
# Renders a labeled data field.
|
||||
attr :label, :string, required: true
|
||||
attr :value, :string, default: nil
|
||||
attr :class, :string, default: ""
|
||||
slot :inner_block
|
||||
|
||||
defp data_field(assigns) do
|
||||
~H"""
|
||||
<dl class={@class}>
|
||||
<dt class="text-sm font-medium text-base-content/70">{@label}</dt>
|
||||
<dd class="mt-1 text-base-content">
|
||||
<%= if @inner_block != [] do %>
|
||||
{render_slot(@inner_block)}
|
||||
<% else %>
|
||||
{display_value(@value)}
|
||||
<% end %>
|
||||
</dd>
|
||||
</dl>
|
||||
"""
|
||||
end
|
||||
|
||||
# -----------------------------------------------------------------
|
||||
# Helper Functions
|
||||
# -----------------------------------------------------------------
|
||||
|
||||
defp display_value(nil), do: ""
|
||||
defp display_value(""), do: ""
|
||||
defp display_value(value), do: value
|
||||
|
||||
defp format_address(member) do
|
||||
street_part =
|
||||
[member.street, member.house_number]
|
||||
|> Enum.filter(&(&1 && &1 != ""))
|
||||
|> Enum.join(" ")
|
||||
|
||||
city_part =
|
||||
[member.postal_code, member.city]
|
||||
|> Enum.filter(&(&1 && &1 != ""))
|
||||
|> Enum.join(" ")
|
||||
|
||||
[street_part, city_part]
|
||||
|> Enum.filter(&(&1 != ""))
|
||||
|> Enum.join(", ")
|
||||
|> case do
|
||||
"" -> nil
|
||||
address -> address
|
||||
end
|
||||
end
|
||||
|
||||
defp format_date(nil), do: nil
|
||||
|
||||
defp format_date(%Date{} = date) do
|
||||
Calendar.strftime(date, "%d.%m.%Y")
|
||||
end
|
||||
|
||||
defp format_date(date), do: to_string(date)
|
||||
|
||||
# Sorts custom field values by custom field name
|
||||
defp sort_custom_field_values(custom_field_values) do
|
||||
Enum.sort_by(custom_field_values, fn cfv ->
|
||||
(cfv.custom_field && cfv.custom_field.name) || ""
|
||||
end)
|
||||
end
|
||||
|
||||
# Formats custom field value based on type
|
||||
defp format_custom_field_value(%Ash.Union{value: value, type: type}, _expected_type) do
|
||||
format_custom_field_value(value, type)
|
||||
end
|
||||
|
||||
defp format_custom_field_value(nil, _type), do: "—"
|
||||
|
||||
defp format_custom_field_value(value, :boolean) when is_boolean(value) do
|
||||
if value, do: gettext("Yes"), else: gettext("No")
|
||||
end
|
||||
|
||||
defp format_custom_field_value(%Date{} = date, :date) do
|
||||
Calendar.strftime(date, "%d.%m.%Y")
|
||||
end
|
||||
|
||||
defp format_custom_field_value(value, :email) when is_binary(value) do
|
||||
assigns = %{email: value}
|
||||
|
||||
~H"""
|
||||
<a href={"mailto:#{@email}"} class="text-blue-700 hover:text-blue-800 underline">{@email}</a>
|
||||
"""
|
||||
end
|
||||
|
||||
defp format_custom_field_value(value, :integer) when is_integer(value) do
|
||||
Integer.to_string(value)
|
||||
end
|
||||
|
||||
defp format_custom_field_value(value, _type) when is_binary(value) do
|
||||
if String.trim(value) == "", do: "—", else: value
|
||||
end
|
||||
|
||||
defp format_custom_field_value(value, _type), do: to_string(value)
|
||||
end
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ defmodule MvWeb.UserLive.Form do
|
|||
<:subtitle>{gettext("Use this form to manage user records in your database.")}</:subtitle>
|
||||
</.header>
|
||||
|
||||
<.form for={@form} id="user-form" phx-change="validate" phx-submit="save">
|
||||
<.form class="max-w-xl" for={@form} id="user-form" phx-change="validate" phx-submit="save">
|
||||
<.input field={@form[:email]} label={gettext("Email")} required type="email" />
|
||||
|
||||
<!-- Password Section -->
|
||||
|
|
@ -61,7 +61,7 @@ defmodule MvWeb.UserLive.Form do
|
|||
</label>
|
||||
|
||||
<%= if @show_password_fields do %>
|
||||
<div class="mt-4 space-y-4 p-4 bg-gray-50 rounded-lg">
|
||||
<div class="p-4 mt-4 space-y-4 rounded-lg bg-gray-50">
|
||||
<.input
|
||||
field={@form[:password]}
|
||||
label={gettext("Password")}
|
||||
|
|
@ -83,7 +83,7 @@ defmodule MvWeb.UserLive.Form do
|
|||
|
||||
<div class="text-sm text-gray-600">
|
||||
<p><strong>{gettext("Password requirements")}:</strong></p>
|
||||
<ul class="list-disc list-inside text-xs mt-1 space-y-1">
|
||||
<ul class="mt-1 space-y-1 text-xs list-disc list-inside">
|
||||
<li>{gettext("At least 8 characters")}</li>
|
||||
<li>{gettext("Include both letters and numbers")}</li>
|
||||
<li>{gettext("Consider using special characters")}</li>
|
||||
|
|
@ -91,7 +91,7 @@ defmodule MvWeb.UserLive.Form do
|
|||
</div>
|
||||
|
||||
<%= if @user do %>
|
||||
<div class="mt-3 p-3 bg-orange-50 border border-orange-200 rounded">
|
||||
<div class="p-3 mt-3 border border-orange-200 rounded bg-orange-50">
|
||||
<p class="text-sm text-orange-800">
|
||||
<strong>{gettext("Admin Note")}:</strong> {gettext(
|
||||
"As an administrator, you can directly set a new password for this user using the same secure Ash Authentication system."
|
||||
|
|
@ -102,7 +102,7 @@ defmodule MvWeb.UserLive.Form do
|
|||
</div>
|
||||
<% else %>
|
||||
<%= if @user do %>
|
||||
<div class="mt-4 p-4 bg-blue-50 rounded-lg">
|
||||
<div class="p-4 mt-4 rounded-lg bg-blue-50">
|
||||
<p class="text-sm text-blue-800">
|
||||
<strong>{gettext("Note")}:</strong> {gettext(
|
||||
"Check 'Change Password' above to set a new password for this user."
|
||||
|
|
@ -110,7 +110,7 @@ defmodule MvWeb.UserLive.Form do
|
|||
</p>
|
||||
</div>
|
||||
<% else %>
|
||||
<div class="mt-4 p-4 bg-yellow-50 rounded-lg">
|
||||
<div class="p-4 mt-4 rounded-lg bg-yellow-50">
|
||||
<p class="text-sm text-yellow-800">
|
||||
<strong>{gettext("Note")}:</strong> {gettext(
|
||||
"User will be created without a password. Check 'Set Password' to add one."
|
||||
|
|
@ -123,11 +123,11 @@ defmodule MvWeb.UserLive.Form do
|
|||
|
||||
<!-- Member Linking Section -->
|
||||
<div class="mt-6">
|
||||
<h2 class="text-base font-semibold mb-3">{gettext("Linked Member")}</h2>
|
||||
<h2 class="mb-3 text-base font-semibold">{gettext("Linked Member")}</h2>
|
||||
|
||||
<%= if @user && @user.member && !@unlink_member do %>
|
||||
<!-- Show linked member with unlink button -->
|
||||
<div class="p-4 bg-green-50 border border-green-200 rounded-lg">
|
||||
<div class="p-4 border border-green-200 rounded-lg bg-green-50">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="font-medium text-green-900">
|
||||
|
|
@ -147,7 +147,7 @@ defmodule MvWeb.UserLive.Form do
|
|||
<% else %>
|
||||
<%= if @unlink_member do %>
|
||||
<!-- Show unlink pending message -->
|
||||
<div class="p-4 bg-yellow-50 border border-yellow-200 rounded-lg">
|
||||
<div class="p-4 border border-yellow-200 rounded-lg bg-yellow-50">
|
||||
<p class="text-sm text-yellow-800">
|
||||
<strong>{gettext("Unlinking scheduled")}:</strong> {gettext(
|
||||
"Member will be unlinked when you save. Cannot select new member until saved."
|
||||
|
|
@ -219,7 +219,7 @@ defmodule MvWeb.UserLive.Form do
|
|||
</div>
|
||||
|
||||
<%= if @user && @user.email && @available_members != [] && Enum.all?(@available_members, &(&1.email == to_string(@user.email))) do %>
|
||||
<div class="p-3 bg-yellow-50 border border-yellow-200 rounded">
|
||||
<div class="p-3 border border-yellow-200 rounded bg-yellow-50">
|
||||
<p class="text-sm text-yellow-800">
|
||||
<strong>{gettext("Note")}:</strong> {gettext(
|
||||
"A member with this email already exists. To link with a different member, please change one of the email addresses first."
|
||||
|
|
@ -231,12 +231,12 @@ defmodule MvWeb.UserLive.Form do
|
|||
<%= if @selected_member_id && @selected_member_name do %>
|
||||
<div
|
||||
id="member-selected"
|
||||
class="mt-2 p-3 bg-blue-50 border border-blue-200 rounded-lg"
|
||||
class="p-3 mt-2 border border-blue-200 rounded-lg bg-blue-50"
|
||||
>
|
||||
<p class="text-sm text-blue-800">
|
||||
<strong>{gettext("Selected")}:</strong> {@selected_member_name}
|
||||
</p>
|
||||
<p class="text-xs text-blue-600 mt-1">
|
||||
<p class="mt-1 text-xs text-blue-600">
|
||||
{gettext("Save to confirm linking.")}
|
||||
</p>
|
||||
</div>
|
||||
|
|
@ -245,10 +245,12 @@ defmodule MvWeb.UserLive.Form do
|
|||
<% end %>
|
||||
</div>
|
||||
|
||||
<.button phx-disable-with={gettext("Saving...")} variant="primary">
|
||||
{gettext("Save User")}
|
||||
</.button>
|
||||
<.button navigate={return_path(@return_to, @user)}>{gettext("Cancel")}</.button>
|
||||
<div class="mt-4">
|
||||
<.button phx-disable-with={gettext("Saving...")} variant="primary">
|
||||
{gettext("Save User")}
|
||||
</.button>
|
||||
<.button navigate={return_path(@return_to, @user)}>{gettext("Cancel")}</.button>
|
||||
</div>
|
||||
</.form>
|
||||
</Layouts.app>
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -49,7 +49,6 @@
|
|||
>
|
||||
{user.email}
|
||||
</:col>
|
||||
<:col :let={user} label={gettext("OIDC ID")}>{user.oidc_id}</:col>
|
||||
<:col :let={user} label={gettext("Linked Member")}>
|
||||
<%= if user.member do %>
|
||||
{user.member.first_name} {user.member.last_name}
|
||||
|
|
|
|||
|
|
@ -46,9 +46,7 @@ defmodule MvWeb.UserLive.Show do
|
|||
</.header>
|
||||
|
||||
<.list>
|
||||
<:item title={gettext("ID")}>{@user.id}</:item>
|
||||
<:item title={gettext("Email")}>{@user.email}</:item>
|
||||
<:item title={gettext("OIDC ID")}>{@user.oidc_id || gettext("Not set")}</:item>
|
||||
<:item title={gettext("Password Authentication")}>
|
||||
{if @user.hashed_password, do: gettext("Enabled"), else: gettext("Not enabled")}
|
||||
</:item>
|
||||
|
|
@ -56,13 +54,13 @@ defmodule MvWeb.UserLive.Show do
|
|||
<%= if @user.member do %>
|
||||
<.link
|
||||
navigate={~p"/members/#{@user.member}"}
|
||||
class="text-blue-600 hover:text-blue-800 underline"
|
||||
class="text-blue-600 underline hover:text-blue-800"
|
||||
>
|
||||
<.icon name="hero-users" class="h-4 w-4 inline mr-1" />
|
||||
<.icon name="hero-users" class="inline w-4 h-4 mr-1" />
|
||||
{@user.member.first_name} {@user.member.last_name}
|
||||
</.link>
|
||||
<% else %>
|
||||
<span class="text-gray-500 italic">{gettext("No member linked")}</span>
|
||||
<span class="italic text-gray-500">{gettext("No member linked")}</span>
|
||||
<% end %>
|
||||
</:item>
|
||||
</.list>
|
||||
|
|
|
|||
|
|
@ -55,12 +55,6 @@ defmodule MvWeb.Router do
|
|||
live "/members/:id", MemberLive.Show, :show
|
||||
live "/members/:id/show/edit", MemberLive.Show, :edit
|
||||
|
||||
live "/custom_fields", CustomFieldLive.Index, :index
|
||||
live "/custom_fields/new", CustomFieldLive.Form, :new
|
||||
live "/custom_fields/:id/edit", CustomFieldLive.Form, :edit
|
||||
live "/custom_fields/:id", CustomFieldLive.Show, :show
|
||||
live "/custom_fields/:id/show/edit", CustomFieldLive.Show, :edit
|
||||
|
||||
live "/custom_field_values", CustomFieldValueLive.Index, :index
|
||||
live "/custom_field_values/new", CustomFieldValueLive.Form, :new
|
||||
live "/custom_field_values/:id/edit", CustomFieldValueLive.Form, :edit
|
||||
|
|
@ -73,6 +67,13 @@ defmodule MvWeb.Router do
|
|||
live "/users/:id", UserLive.Show, :show
|
||||
live "/users/:id/show/edit", UserLive.Show, :edit
|
||||
|
||||
live "/settings", GlobalSettingsLive
|
||||
|
||||
# Contribution Management (Mock-ups)
|
||||
live "/contribution_types", ContributionTypeLive.Index, :index
|
||||
live "/contribution_settings", ContributionSettingsLive
|
||||
live "/contributions/member/:id", ContributionPeriodLive.Show, :show
|
||||
|
||||
post "/set_locale", LocaleController, :set_locale
|
||||
end
|
||||
|
||||
|
|
|
|||
3
mix.exs
3
mix.exs
|
|
@ -12,7 +12,8 @@ defmodule Mv.MixProject do
|
|||
compilers: [:phoenix_live_view] ++ Mix.compilers(),
|
||||
aliases: aliases(),
|
||||
deps: deps(),
|
||||
listeners: [Phoenix.CodeReloader]
|
||||
listeners: [Phoenix.CodeReloader],
|
||||
gettext: [write_reference_line_numbers: false]
|
||||
]
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -36,7 +36,7 @@ msgstr ""
|
|||
msgid "Need an account?"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex:268
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex
|
||||
#, elixir-autogen
|
||||
msgid "Password"
|
||||
msgstr ""
|
||||
|
|
@ -65,78 +65,77 @@ msgstr ""
|
|||
msgid "Your password has successfully been reset"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex:254
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "An account with email %{email} already exists. Please enter your password to link your OIDC account."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex:289
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Cancel"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex:163
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Incorrect password. Please try again."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex:37
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Invalid session. Please try again."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex:281
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Link Account"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex:252
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Link OIDC Account"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex:280
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Linking..."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex:40
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Session expired. Please try again."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex:209
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Your OIDC account has been successfully linked! Redirecting to complete sign-in..."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex:76
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Account activated! Redirecting to complete sign-in..."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex:119
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex:123
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Failed to link account. Please try again or contact support."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex:108
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "The email address from your OIDC provider is already registered to another account. Please change your email in the identity provider or contact support."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex:98
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "This OIDC account is already linked to another user. Please contact support."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex:235
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Language selection"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex:242
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Select language"
|
||||
msgstr ""
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ msgstr "Falls diese*r Benutzer*in bekannt ist, wird jetzt eine Email mit einer A
|
|||
msgid "Need an account?"
|
||||
msgstr "Konto anlegen?"
|
||||
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex:268
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex
|
||||
#, elixir-autogen
|
||||
msgid "Password"
|
||||
msgstr "Passwort"
|
||||
|
|
@ -64,78 +64,77 @@ msgstr "Anmelden..."
|
|||
msgid "Your password has successfully been reset"
|
||||
msgstr "Das Passwort wurde erfolgreich zurückgesetzt"
|
||||
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex:254
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "An account with email %{email} already exists. Please enter your password to link your OIDC account."
|
||||
msgstr "Ein Konto mit der E-Mail %{email} existiert bereits. Bitte geben Sie Ihr Passwort ein, um Ihr OIDC-Konto zu verknüpfen."
|
||||
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex:289
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Cancel"
|
||||
msgstr "Abbrechen"
|
||||
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex:163
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Incorrect password. Please try again."
|
||||
msgstr "Falsches Passwort. Bitte versuchen Sie es erneut."
|
||||
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex:37
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Invalid session. Please try again."
|
||||
msgstr "Ungültige Sitzung. Bitte versuchen Sie es erneut."
|
||||
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex:281
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Link Account"
|
||||
msgstr "Konto verknüpfen"
|
||||
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex:252
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Link OIDC Account"
|
||||
msgstr "OIDC-Konto verknüpfen"
|
||||
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex:280
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Linking..."
|
||||
msgstr "Verknüpfen..."
|
||||
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex:40
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Session expired. Please try again."
|
||||
msgstr "Sitzung abgelaufen. Bitte versuchen Sie es erneut."
|
||||
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex:209
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Your OIDC account has been successfully linked! Redirecting to complete sign-in..."
|
||||
msgstr "Ihr OIDC-Konto wurde erfolgreich verknüpft! Sie werden zur Anmeldung weitergeleitet..."
|
||||
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex:76
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Account activated! Redirecting to complete sign-in..."
|
||||
msgstr "Konto aktiviert! Sie werden zur Anmeldung weitergeleitet..."
|
||||
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex:119
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex:123
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Failed to link account. Please try again or contact support."
|
||||
msgstr "Verknüpfung des Kontos fehlgeschlagen. Bitte versuchen Sie es erneut oder kontaktieren Sie den Support."
|
||||
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex:108
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "The email address from your OIDC provider is already registered to another account. Please change your email in the identity provider or contact support."
|
||||
msgstr "Die E-Mail-Adresse aus Ihrem OIDC-Provider ist bereits für ein anderes Konto registriert. Bitte ändern Sie Ihre E-Mail-Adresse im Identity-Provider oder kontaktieren Sie den Support."
|
||||
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex:98
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "This OIDC account is already linked to another user. Please contact support."
|
||||
msgstr "Dieses OIDC-Konto ist bereits mit einem anderen Benutzer verknüpft. Bitte kontaktieren Sie den Support."
|
||||
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex:235
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Language selection"
|
||||
msgstr "Sprachauswahl"
|
||||
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex:242
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Select language"
|
||||
msgstr "Sprache auswählen"
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
|
|
@ -32,7 +32,7 @@ msgstr ""
|
|||
msgid "Need an account?"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex:268
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex
|
||||
#, elixir-autogen
|
||||
msgid "Password"
|
||||
msgstr ""
|
||||
|
|
@ -61,78 +61,77 @@ msgstr ""
|
|||
msgid "Your password has successfully been reset"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex:254
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "An account with email %{email} already exists. Please enter your password to link your OIDC account."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex:289
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Cancel"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex:163
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Incorrect password. Please try again."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex:37
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Invalid session. Please try again."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex:281
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Link Account"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex:252
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Link OIDC Account"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex:280
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Linking..."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex:40
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Session expired. Please try again."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex:209
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Your OIDC account has been successfully linked! Redirecting to complete sign-in..."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex:76
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Account activated! Redirecting to complete sign-in..."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex:119
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex:123
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Failed to link account. Please try again or contact support."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex:108
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "The email address from your OIDC provider is already registered to another account. Please change your email in the identity provider or contact support."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex:98
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "This OIDC account is already linked to another user. Please contact support."
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex:235
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Language selection"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex:242
|
||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Select language"
|
||||
msgstr ""
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,21 @@
|
|||
defmodule Mv.Repo.Migrations.AddShowInOverviewToCustomFields do
|
||||
@moduledoc """
|
||||
Updates resources based on their most recent snapshots.
|
||||
|
||||
This file was autogenerated with `mix ash_postgres.generate_migrations`
|
||||
"""
|
||||
|
||||
use Ecto.Migration
|
||||
|
||||
def up do
|
||||
alter table(:custom_fields) do
|
||||
add :show_in_overview, :boolean, null: false, default: true
|
||||
end
|
||||
end
|
||||
|
||||
def down do
|
||||
alter table(:custom_fields) do
|
||||
remove :show_in_overview
|
||||
end
|
||||
end
|
||||
end
|
||||
31
priv/repo/migrations/20251127134451_add_settings_table.exs
Normal file
31
priv/repo/migrations/20251127134451_add_settings_table.exs
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
defmodule Mv.Repo.Migrations.AddSettingsTable do
|
||||
@moduledoc """
|
||||
Updates resources based on their most recent snapshots.
|
||||
|
||||
This file was autogenerated with `mix ash_postgres.generate_migrations`
|
||||
"""
|
||||
|
||||
use Ecto.Migration
|
||||
|
||||
def up do
|
||||
create table(:settings, primary_key: false) do
|
||||
add :id, :uuid, null: false, default: fragment("gen_random_uuid()"), primary_key: true
|
||||
add :club_name, :text, null: false
|
||||
|
||||
add :inserted_at, :utc_datetime_usec,
|
||||
null: false,
|
||||
default: fragment("(now() AT TIME ZONE 'utc')")
|
||||
|
||||
add :updated_at, :utc_datetime_usec,
|
||||
null: false,
|
||||
default: fragment("(now() AT TIME ZONE 'utc')")
|
||||
end
|
||||
|
||||
# Note: Singleton pattern is enforced at application level via get_settings/0
|
||||
# which creates the record if it doesn't exist and only allows updates
|
||||
end
|
||||
|
||||
def down do
|
||||
drop table(:settings)
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
defmodule Mv.Repo.Migrations.AddMemberFieldVisibilityToSettings do
|
||||
@moduledoc """
|
||||
Updates resources based on their most recent snapshots.
|
||||
|
||||
This file was autogenerated with `mix ash_postgres.generate_migrations`
|
||||
"""
|
||||
|
||||
use Ecto.Migration
|
||||
|
||||
def up do
|
||||
alter table(:settings) do
|
||||
add :member_field_visibility, :map
|
||||
end
|
||||
end
|
||||
|
||||
def down do
|
||||
alter table(:settings) do
|
||||
remove :member_field_visibility
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
defmodule Mv.Repo.Migrations.RemoveBirthDateFromMembers do
|
||||
@moduledoc """
|
||||
Removes the birth_date column from the members table.
|
||||
|
||||
The birth_date field has been removed from the application because most users
|
||||
don't record birthday data. Users who need this can use a custom field instead.
|
||||
|
||||
This migration also updates the search_vector trigger to remove birth_date.
|
||||
"""
|
||||
|
||||
use Ecto.Migration
|
||||
|
||||
def up do
|
||||
# Update the trigger function to remove birth_date from search_vector
|
||||
execute("""
|
||||
CREATE OR REPLACE FUNCTION members_search_vector_trigger() RETURNS trigger AS $$
|
||||
BEGIN
|
||||
NEW.search_vector :=
|
||||
setweight(to_tsvector('simple', coalesce(NEW.first_name, '')), 'A') ||
|
||||
setweight(to_tsvector('simple', coalesce(NEW.last_name, '')), 'A') ||
|
||||
setweight(to_tsvector('simple', coalesce(NEW.email, '')), 'B') ||
|
||||
setweight(to_tsvector('simple', coalesce(NEW.phone_number, '')), 'C') ||
|
||||
setweight(to_tsvector('simple', coalesce(NEW.join_date::text, '')), 'D') ||
|
||||
setweight(to_tsvector('simple', coalesce(NEW.exit_date::text, '')), 'D') ||
|
||||
setweight(to_tsvector('simple', coalesce(NEW.notes, '')), 'B') ||
|
||||
setweight(to_tsvector('simple', coalesce(NEW.city, '')), 'C') ||
|
||||
setweight(to_tsvector('simple', coalesce(NEW.street, '')), 'C') ||
|
||||
setweight(to_tsvector('simple', coalesce(NEW.house_number::text, '')), 'C') ||
|
||||
setweight(to_tsvector('simple', coalesce(NEW.postal_code::text, '')), 'C');
|
||||
RETURN NEW;
|
||||
END
|
||||
$$ LANGUAGE plpgsql;
|
||||
""")
|
||||
|
||||
# Remove the birth_date column
|
||||
alter table(:members) do
|
||||
remove :birth_date
|
||||
end
|
||||
end
|
||||
|
||||
def down do
|
||||
# Add the birth_date column back
|
||||
alter table(:members) do
|
||||
add :birth_date, :date
|
||||
end
|
||||
|
||||
# Restore the trigger function with birth_date
|
||||
execute("""
|
||||
CREATE OR REPLACE FUNCTION members_search_vector_trigger() RETURNS trigger AS $$
|
||||
BEGIN
|
||||
NEW.search_vector :=
|
||||
setweight(to_tsvector('simple', coalesce(NEW.first_name, '')), 'A') ||
|
||||
setweight(to_tsvector('simple', coalesce(NEW.last_name, '')), 'A') ||
|
||||
setweight(to_tsvector('simple', coalesce(NEW.email, '')), 'B') ||
|
||||
setweight(to_tsvector('simple', coalesce(NEW.birth_date::text, '')), 'C') ||
|
||||
setweight(to_tsvector('simple', coalesce(NEW.phone_number, '')), 'C') ||
|
||||
setweight(to_tsvector('simple', coalesce(NEW.join_date::text, '')), 'D') ||
|
||||
setweight(to_tsvector('simple', coalesce(NEW.exit_date::text, '')), 'D') ||
|
||||
setweight(to_tsvector('simple', coalesce(NEW.notes, '')), 'B') ||
|
||||
setweight(to_tsvector('simple', coalesce(NEW.city, '')), 'C') ||
|
||||
setweight(to_tsvector('simple', coalesce(NEW.street, '')), 'C') ||
|
||||
setweight(to_tsvector('simple', coalesce(NEW.house_number::text, '')), 'C') ||
|
||||
setweight(to_tsvector('simple', coalesce(NEW.postal_code::text, '')), 'C');
|
||||
RETURN NEW;
|
||||
END
|
||||
$$ LANGUAGE plpgsql;
|
||||
""")
|
||||
end
|
||||
end
|
||||
|
|
@ -112,7 +112,6 @@ for member_attrs <- [
|
|||
first_name: "Hans",
|
||||
last_name: "Müller",
|
||||
email: "hans.mueller@example.de",
|
||||
birth_date: ~D[1985-06-15],
|
||||
join_date: ~D[2023-01-15],
|
||||
paid: true,
|
||||
phone_number: "+49301234567",
|
||||
|
|
@ -125,7 +124,6 @@ for member_attrs <- [
|
|||
first_name: "Greta",
|
||||
last_name: "Schmidt",
|
||||
email: "greta.schmidt@example.de",
|
||||
birth_date: ~D[1990-03-22],
|
||||
join_date: ~D[2023-02-01],
|
||||
paid: false,
|
||||
phone_number: "+49309876543",
|
||||
|
|
@ -139,7 +137,6 @@ for member_attrs <- [
|
|||
first_name: "Friedrich",
|
||||
last_name: "Wagner",
|
||||
email: "friedrich.wagner@example.de",
|
||||
birth_date: ~D[1978-11-08],
|
||||
join_date: ~D[2022-11-10],
|
||||
paid: true,
|
||||
phone_number: "+49301122334",
|
||||
|
|
@ -151,7 +148,6 @@ for member_attrs <- [
|
|||
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",
|
||||
|
|
@ -186,7 +182,6 @@ linked_members = [
|
|||
first_name: "Maria",
|
||||
last_name: "Weber",
|
||||
email: "maria.weber@example.de",
|
||||
birth_date: ~D[1992-07-14],
|
||||
join_date: ~D[2023-03-15],
|
||||
paid: true,
|
||||
phone_number: "+49301357924",
|
||||
|
|
@ -202,7 +197,6 @@ linked_members = [
|
|||
first_name: "Thomas",
|
||||
last_name: "Klein",
|
||||
email: "thomas.klein@example.de",
|
||||
birth_date: ~D[1988-12-03],
|
||||
join_date: ~D[2023-04-01],
|
||||
paid: false,
|
||||
phone_number: "+49302468135",
|
||||
|
|
@ -323,8 +317,21 @@ if friedrich = find_member.("friedrich.wagner@example.de") do
|
|||
end)
|
||||
end
|
||||
|
||||
# Create or update global settings (singleton)
|
||||
default_club_name = System.get_env("ASSOCIATION_NAME") || "Club Name"
|
||||
|
||||
case Membership.get_settings() do
|
||||
{:ok, existing_settings} ->
|
||||
# Settings exist, update if club_name is different from env var
|
||||
if existing_settings.club_name != default_club_name do
|
||||
{:ok, _updated} =
|
||||
Membership.update_settings(existing_settings, %{club_name: default_club_name})
|
||||
end
|
||||
end
|
||||
|
||||
IO.puts("✅ Seeds completed successfully!")
|
||||
IO.puts("📝 Created sample data:")
|
||||
IO.puts(" - Global settings: club_name = #{default_club_name}")
|
||||
IO.puts(" - Custom fields: 12 fields (String, Date, Boolean, Email, + 8 realistic fields)")
|
||||
IO.puts(" - Admin user: admin@mv.local (password: testpassword)")
|
||||
IO.puts(" - Sample members: Hans, Greta, Friedrich")
|
||||
|
|
|
|||
118
priv/resource_snapshots/repo/custom_fields/20251119160509.json
Normal file
118
priv/resource_snapshots/repo/custom_fields/20251119160509.json
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
{
|
||||
"attributes": [
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "fragment(\"gen_random_uuid()\")",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": true,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "id",
|
||||
"type": "uuid"
|
||||
},
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "name",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "value_type",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"allow_nil?": true,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "description",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "false",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "immutable",
|
||||
"type": "boolean"
|
||||
},
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "false",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "required",
|
||||
"type": "boolean"
|
||||
},
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "false",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "show_in_overview",
|
||||
"type": "boolean"
|
||||
}
|
||||
],
|
||||
"base_filter": null,
|
||||
"check_constraints": [],
|
||||
"custom_indexes": [],
|
||||
"custom_statements": [],
|
||||
"has_create_action": true,
|
||||
"hash": "9FBFC42DA896058F88DEDAE774614919222BF2EF2F8CB27386D02C2CE67F03DE",
|
||||
"identities": [
|
||||
{
|
||||
"all_tenants?": false,
|
||||
"base_filter": null,
|
||||
"index_name": "custom_fields_unique_name_index",
|
||||
"keys": [
|
||||
{
|
||||
"type": "atom",
|
||||
"value": "name"
|
||||
}
|
||||
],
|
||||
"name": "unique_name",
|
||||
"nils_distinct?": true,
|
||||
"where": null
|
||||
}
|
||||
],
|
||||
"multitenancy": {
|
||||
"attribute": null,
|
||||
"global": null,
|
||||
"strategy": null
|
||||
},
|
||||
"repo": "Elixir.Mv.Repo",
|
||||
"schema": null,
|
||||
"table": "custom_fields"
|
||||
}
|
||||
144
priv/resource_snapshots/repo/custom_fields/20251201115939.json
Normal file
144
priv/resource_snapshots/repo/custom_fields/20251201115939.json
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
{
|
||||
"attributes": [
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "fragment(\"gen_random_uuid()\")",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": true,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "id",
|
||||
"type": "uuid"
|
||||
},
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "name",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "slug",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "value_type",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"allow_nil?": true,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "description",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "false",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "immutable",
|
||||
"type": "boolean"
|
||||
},
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "false",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "required",
|
||||
"type": "boolean"
|
||||
},
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "true",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "show_in_overview",
|
||||
"type": "boolean"
|
||||
}
|
||||
],
|
||||
"base_filter": null,
|
||||
"check_constraints": [],
|
||||
"custom_indexes": [],
|
||||
"custom_statements": [],
|
||||
"has_create_action": true,
|
||||
"hash": "D31160C95D3D32BA715D493DE2D2B8D6572E0EC68AE14B928D99975BC8A81542",
|
||||
"identities": [
|
||||
{
|
||||
"all_tenants?": false,
|
||||
"base_filter": null,
|
||||
"index_name": "custom_fields_unique_name_index",
|
||||
"keys": [
|
||||
{
|
||||
"type": "atom",
|
||||
"value": "name"
|
||||
}
|
||||
],
|
||||
"name": "unique_name",
|
||||
"nils_distinct?": true,
|
||||
"where": null
|
||||
},
|
||||
{
|
||||
"all_tenants?": false,
|
||||
"base_filter": null,
|
||||
"index_name": "custom_fields_unique_slug_index",
|
||||
"keys": [
|
||||
{
|
||||
"type": "atom",
|
||||
"value": "slug"
|
||||
}
|
||||
],
|
||||
"name": "unique_slug",
|
||||
"nils_distinct?": true,
|
||||
"where": null
|
||||
}
|
||||
],
|
||||
"multitenancy": {
|
||||
"attribute": null,
|
||||
"global": null,
|
||||
"strategy": null
|
||||
},
|
||||
"repo": "Elixir.Mv.Repo",
|
||||
"schema": null,
|
||||
"table": "custom_fields"
|
||||
}
|
||||
67
priv/resource_snapshots/repo/settings/20251127134451.json
Normal file
67
priv/resource_snapshots/repo/settings/20251127134451.json
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
{
|
||||
"attributes": [
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "fragment(\"gen_random_uuid()\")",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": true,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "id",
|
||||
"type": "uuid"
|
||||
},
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "club_name",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "fragment(\"(now() AT TIME ZONE 'utc')\")",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "inserted_at",
|
||||
"type": "utc_datetime_usec"
|
||||
},
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "fragment(\"(now() AT TIME ZONE 'utc')\")",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "updated_at",
|
||||
"type": "utc_datetime_usec"
|
||||
}
|
||||
],
|
||||
"base_filter": null,
|
||||
"check_constraints": [],
|
||||
"custom_indexes": [],
|
||||
"custom_statements": [],
|
||||
"has_create_action": true,
|
||||
"hash": "353EB39F18B97C596A77A78A060FB9DE075AAD731F74F64AB62D357CBCDEC914",
|
||||
"identities": [],
|
||||
"multitenancy": {
|
||||
"attribute": null,
|
||||
"global": null,
|
||||
"strategy": null
|
||||
},
|
||||
"repo": "Elixir.Mv.Repo",
|
||||
"schema": null,
|
||||
"table": "settings"
|
||||
}
|
||||
79
priv/resource_snapshots/repo/settings/20251201115939.json
Normal file
79
priv/resource_snapshots/repo/settings/20251201115939.json
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
{
|
||||
"attributes": [
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "fragment(\"gen_random_uuid()\")",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": true,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "id",
|
||||
"type": "uuid"
|
||||
},
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "club_name",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"allow_nil?": true,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "member_field_visibility",
|
||||
"type": "map"
|
||||
},
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "fragment(\"(now() AT TIME ZONE 'utc')\")",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "inserted_at",
|
||||
"type": "utc_datetime_usec"
|
||||
},
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "fragment(\"(now() AT TIME ZONE 'utc')\")",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "updated_at",
|
||||
"type": "utc_datetime_usec"
|
||||
}
|
||||
],
|
||||
"base_filter": null,
|
||||
"check_constraints": [],
|
||||
"custom_indexes": [],
|
||||
"custom_statements": [],
|
||||
"has_create_action": true,
|
||||
"hash": "F2823210AA9E6476074A218375F64CD80E7F9E04EECC4E94D4C7FD31A773C016",
|
||||
"identities": [],
|
||||
"multitenancy": {
|
||||
"attribute": null,
|
||||
"global": null,
|
||||
"strategy": null
|
||||
},
|
||||
"repo": "Elixir.Mv.Repo",
|
||||
"schema": null,
|
||||
"table": "settings"
|
||||
}
|
||||
77
test/membership/custom_field_show_in_overview_test.exs
Normal file
77
test/membership/custom_field_show_in_overview_test.exs
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
defmodule Mv.Membership.CustomFieldShowInOverviewTest do
|
||||
@moduledoc """
|
||||
Tests for CustomField show_in_overview attribute.
|
||||
|
||||
Tests cover:
|
||||
- Creating custom fields with show_in_overview: true
|
||||
- Creating custom fields with show_in_overview: false (default)
|
||||
- Updating show_in_overview to true
|
||||
- Updating show_in_overview to false
|
||||
"""
|
||||
use Mv.DataCase, async: true
|
||||
|
||||
alias Mv.Membership.CustomField
|
||||
|
||||
describe "show_in_overview attribute" do
|
||||
test "creates custom field with show_in_overview: true" do
|
||||
assert {:ok, custom_field} =
|
||||
CustomField
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
name: "test_field_show",
|
||||
value_type: :string,
|
||||
show_in_overview: true
|
||||
})
|
||||
|> Ash.create()
|
||||
|
||||
assert custom_field.show_in_overview == true
|
||||
end
|
||||
|
||||
test "creates custom field with show_in_overview: true (default)" do
|
||||
assert {:ok, custom_field} =
|
||||
CustomField
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
name: "test_field_hide",
|
||||
value_type: :string
|
||||
})
|
||||
|> Ash.create()
|
||||
|
||||
assert custom_field.show_in_overview == true
|
||||
end
|
||||
|
||||
test "updates show_in_overview to true" do
|
||||
{:ok, custom_field} =
|
||||
CustomField
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
name: "test_field_update",
|
||||
value_type: :string,
|
||||
show_in_overview: false
|
||||
})
|
||||
|> Ash.create()
|
||||
|
||||
assert {:ok, updated_field} =
|
||||
custom_field
|
||||
|> Ash.Changeset.for_update(:update, %{show_in_overview: true})
|
||||
|> Ash.update()
|
||||
|
||||
assert updated_field.show_in_overview == true
|
||||
end
|
||||
|
||||
test "updates show_in_overview to false" do
|
||||
{:ok, custom_field} =
|
||||
CustomField
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
name: "test_field_update2",
|
||||
value_type: :string,
|
||||
show_in_overview: true
|
||||
})
|
||||
|> Ash.create()
|
||||
|
||||
assert {:ok, updated_field} =
|
||||
custom_field
|
||||
|> Ash.Changeset.for_update(:update, %{show_in_overview: false})
|
||||
|> Ash.update()
|
||||
|
||||
assert updated_field.show_in_overview == false
|
||||
end
|
||||
end
|
||||
end
|
||||
80
test/membership/member_field_visibility_test.exs
Normal file
80
test/membership/member_field_visibility_test.exs
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
defmodule Mv.Membership.MemberFieldVisibilityTest do
|
||||
@moduledoc """
|
||||
Tests for member field visibility configuration.
|
||||
|
||||
Tests cover:
|
||||
- Member fields are visible by default (show_in_overview: true)
|
||||
- Member fields can be hidden (show_in_overview: false)
|
||||
- Checking if a specific field is visible
|
||||
- Configuration is stored in Settings resource
|
||||
"""
|
||||
use Mv.DataCase, async: true
|
||||
|
||||
alias Mv.Membership.Member
|
||||
|
||||
describe "show_in_overview?/1" do
|
||||
test "returns true for all member fields by default" do
|
||||
# When no settings exist or member_field_visibility is not configured
|
||||
# Test with fields from constants
|
||||
member_fields = Mv.Constants.member_fields()
|
||||
|
||||
Enum.each(member_fields, fn field ->
|
||||
assert Member.show_in_overview?(field) == true,
|
||||
"Field #{field} should be visible by default"
|
||||
end)
|
||||
end
|
||||
|
||||
test "returns false for fields with show_in_overview: false in settings" do
|
||||
# Get or create settings
|
||||
{:ok, settings} = Mv.Membership.get_settings()
|
||||
|
||||
# Use a field that exists in member fields
|
||||
member_fields = Mv.Constants.member_fields()
|
||||
field_to_hide = List.first(member_fields)
|
||||
field_to_show = List.last(member_fields)
|
||||
|
||||
# Update settings to hide a field (use string keys for JSONB)
|
||||
{:ok, _updated_settings} =
|
||||
Mv.Membership.update_settings(settings, %{
|
||||
member_field_visibility: %{Atom.to_string(field_to_hide) => false}
|
||||
})
|
||||
|
||||
# JSONB may convert atom keys to string keys, so we check via show_in_overview? instead
|
||||
assert Member.show_in_overview?(field_to_hide) == false
|
||||
assert Member.show_in_overview?(field_to_show) == true
|
||||
end
|
||||
|
||||
test "returns true for non-configured fields (default)" do
|
||||
# Get or create settings
|
||||
{:ok, settings} = Mv.Membership.get_settings()
|
||||
|
||||
# Use fields that exist in member fields
|
||||
member_fields = Mv.Constants.member_fields()
|
||||
fields_to_hide = Enum.take(member_fields, 2)
|
||||
fields_to_show = Enum.take(member_fields, -2)
|
||||
|
||||
# Update settings to hide some fields (use string keys for JSONB)
|
||||
visibility_config =
|
||||
Enum.reduce(fields_to_hide, %{}, fn field, acc ->
|
||||
Map.put(acc, Atom.to_string(field), false)
|
||||
end)
|
||||
|
||||
{:ok, _updated_settings} =
|
||||
Mv.Membership.update_settings(settings, %{
|
||||
member_field_visibility: visibility_config
|
||||
})
|
||||
|
||||
# Hidden fields should be false
|
||||
Enum.each(fields_to_hide, fn field ->
|
||||
assert Member.show_in_overview?(field) == false,
|
||||
"Field #{field} should be hidden"
|
||||
end)
|
||||
|
||||
# Unconfigured fields should still be true (default)
|
||||
Enum.each(fields_to_show, fn field ->
|
||||
assert Member.show_in_overview?(field) == true,
|
||||
"Field #{field} should be visible by default"
|
||||
end)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -6,7 +6,6 @@ defmodule Mv.Membership.MemberTest do
|
|||
@valid_attrs %{
|
||||
first_name: "John",
|
||||
last_name: "Doe",
|
||||
birth_date: ~D[1990-01-01],
|
||||
paid: true,
|
||||
email: "john@example.com",
|
||||
phone_number: "+49123456789",
|
||||
|
|
@ -43,12 +42,6 @@ defmodule Mv.Membership.MemberTest do
|
|||
assert error_message(errors, :email) =~ "is not a valid email"
|
||||
end
|
||||
|
||||
test "Birth date is optional but must not be in the future" do
|
||||
attrs = Map.put(@valid_attrs, :birth_date, Date.utc_today() |> Date.add(1))
|
||||
assert {:error, %Ash.Error.Invalid{errors: errors}} = Membership.create_member(attrs)
|
||||
assert error_message(errors, :birth_date) =~ "cannot be in the future"
|
||||
end
|
||||
|
||||
test "Paid is optional but must be boolean if specified" do
|
||||
attrs = Map.put(@valid_attrs, :paid, nil)
|
||||
attrs2 = Map.put(@valid_attrs, :paid, "yes")
|
||||
|
|
|
|||
61
test/membership/setting_env_test.exs
Normal file
61
test/membership/setting_env_test.exs
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
defmodule Mv.Membership.SettingEnvTest do
|
||||
use Mv.DataCase, async: false
|
||||
alias Mv.Membership
|
||||
|
||||
describe "Settings with environment variable" do
|
||||
test "club_name can be set via ASSOCIATION_NAME environment variable" do
|
||||
# Set environment variable
|
||||
System.put_env("ASSOCIATION_NAME", "Test Association from Env")
|
||||
|
||||
try do
|
||||
# Get settings - should use environment variable if no DB value exists
|
||||
{:ok, settings} = Membership.get_settings()
|
||||
|
||||
# If settings don't have a club_name in DB, it should use the env var
|
||||
# This depends on implementation - we'll check that the env var is respected
|
||||
assert settings.club_name != nil
|
||||
after
|
||||
# Clean up
|
||||
System.delete_env("ASSOCIATION_NAME")
|
||||
end
|
||||
end
|
||||
|
||||
test "database value takes precedence over environment variable" do
|
||||
# Set environment variable
|
||||
System.put_env("ASSOCIATION_NAME", "Env Value")
|
||||
|
||||
try do
|
||||
# Set a value in the database
|
||||
{:ok, settings} = Membership.get_settings()
|
||||
{:ok, _updated} = Membership.update_settings(settings, %{club_name: "DB Value"})
|
||||
|
||||
# Get settings again - should use DB value, not env var
|
||||
{:ok, settings_after} = Membership.get_settings()
|
||||
assert settings_after.club_name == "DB Value"
|
||||
after
|
||||
# Clean up
|
||||
System.delete_env("ASSOCIATION_NAME")
|
||||
end
|
||||
end
|
||||
|
||||
test "uses environment variable when database value is not set" do
|
||||
# Set environment variable
|
||||
System.put_env("ASSOCIATION_NAME", "Default from Env")
|
||||
|
||||
try do
|
||||
# Clear database value (if possible) or check that env var is used
|
||||
{:ok, settings} = Membership.get_settings()
|
||||
|
||||
# If club_name is nil or empty in DB, should use env var
|
||||
# This test depends on implementation details
|
||||
# We're testing that the env var fallback works
|
||||
club_name = settings.club_name || System.get_env("ASSOCIATION_NAME")
|
||||
assert club_name != nil
|
||||
assert club_name != ""
|
||||
after
|
||||
# Clean up
|
||||
System.delete_env("ASSOCIATION_NAME")
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
51
test/membership/setting_test.exs
Normal file
51
test/membership/setting_test.exs
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
defmodule Mv.Membership.SettingTest do
|
||||
use Mv.DataCase, async: false
|
||||
alias Mv.Membership
|
||||
|
||||
describe "Settings Resource" do
|
||||
test "can read settings" do
|
||||
# Settings should be a singleton resource
|
||||
assert {:ok, _settings} = Membership.get_settings()
|
||||
end
|
||||
|
||||
test "settings have club_name attribute" do
|
||||
{:ok, settings} = Membership.get_settings()
|
||||
assert Map.has_key?(settings, :club_name)
|
||||
end
|
||||
|
||||
test "can update club_name" do
|
||||
{:ok, settings} = Membership.get_settings()
|
||||
|
||||
assert {:ok, updated_settings} =
|
||||
Membership.update_settings(settings, %{club_name: "New Club Name"})
|
||||
|
||||
assert updated_settings.club_name == "New Club Name"
|
||||
end
|
||||
|
||||
test "club_name is required" do
|
||||
{:ok, settings} = Membership.get_settings()
|
||||
|
||||
assert {:error, %Ash.Error.Invalid{errors: errors}} =
|
||||
Membership.update_settings(settings, %{club_name: nil})
|
||||
|
||||
assert error_message(errors, :club_name) =~ "must be present"
|
||||
end
|
||||
|
||||
test "club_name cannot be empty" do
|
||||
{:ok, settings} = Membership.get_settings()
|
||||
|
||||
assert {:error, %Ash.Error.Invalid{errors: errors}} =
|
||||
Membership.update_settings(settings, %{club_name: ""})
|
||||
|
||||
assert error_message(errors, :club_name) =~ "must be present"
|
||||
end
|
||||
end
|
||||
|
||||
# Helper function to extract error messages
|
||||
defp error_message(errors, field) do
|
||||
errors
|
||||
|> Enum.filter(fn err -> Map.get(err, :field) == field end)
|
||||
|> Enum.map(&Map.get(&1, :message, ""))
|
||||
|> List.first() || ""
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
defmodule MvWeb.Components.FieldVisibilityDropdownComponentTest do
|
||||
use MvWeb.ConnCase, async: true
|
||||
import Phoenix.LiveViewTest
|
||||
|
||||
describe "field visibility dropdown in member view" do
|
||||
test "renders and toggles visibility", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, view, _html} = live(conn, ~p"/members")
|
||||
|
||||
# Renders Dropdown
|
||||
assert has_element?(view, "[data-testid='dropdown-menu']")
|
||||
|
||||
# Opens Dropdown
|
||||
view |> element("[data-testid='dropdown-button']") |> render_click()
|
||||
assert has_element?(view, "#field-visibility-menu")
|
||||
assert has_element?(view, "button[phx-click='select_item'][phx-value-item='email']")
|
||||
assert has_element?(view, "button[phx-click='select_all']")
|
||||
assert has_element?(view, "button[phx-click='select_none']")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -84,5 +84,23 @@ defmodule MvWeb.Layouts.NavbarTest do
|
|||
# Check for correct logout path
|
||||
assert html =~ ~s(href="/sign-out")
|
||||
end
|
||||
|
||||
test "Settings link navigates to global settings page", %{conn: conn} do
|
||||
user = create_test_user(%{email: "test@example.com"})
|
||||
conn = conn_with_oidc_user(conn, user)
|
||||
|
||||
html =
|
||||
render_component(&MvWeb.Layouts.Navbar.navbar/1, %{
|
||||
current_user: user
|
||||
})
|
||||
|
||||
# Check that Settings link exists and points to /settings
|
||||
assert html =~ "Settings"
|
||||
assert html =~ ~s(href="/settings") || html =~ ~s(navigate="/settings")
|
||||
|
||||
# Verify the link actually works by navigating to it
|
||||
{:ok, _view, settings_html} = live(conn, ~p"/settings")
|
||||
assert settings_html =~ "Club Settings"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
183
test/mv_web/components/payment_filter_component_test.exs
Normal file
183
test/mv_web/components/payment_filter_component_test.exs
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
defmodule MvWeb.Components.PaymentFilterComponentTest do
|
||||
@moduledoc """
|
||||
Unit tests for the PaymentFilterComponent.
|
||||
|
||||
Tests cover:
|
||||
- Rendering in all 3 filter states (nil, :paid, :not_paid)
|
||||
- Event emission when selecting options
|
||||
- ARIA attributes for accessibility
|
||||
- Dropdown open/close behavior
|
||||
"""
|
||||
# async: false to prevent PostgreSQL deadlocks when running LiveView tests against DB
|
||||
use MvWeb.ConnCase, async: false
|
||||
|
||||
import Phoenix.LiveViewTest
|
||||
|
||||
describe "rendering" do
|
||||
test "renders with no filter active (nil)", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, view, _html} = live(conn, "/members")
|
||||
|
||||
# Should show "All" text and no badge
|
||||
assert has_element?(view, "#payment-filter")
|
||||
refute has_element?(view, "#payment-filter .badge")
|
||||
end
|
||||
|
||||
test "renders with paid filter active", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, view, _html} = live(conn, "/members?paid_filter=paid")
|
||||
|
||||
# Should show badge when filter is active
|
||||
assert has_element?(view, "#payment-filter .badge")
|
||||
end
|
||||
|
||||
test "renders with not_paid filter active", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, view, _html} = live(conn, "/members?paid_filter=not_paid")
|
||||
|
||||
# Should show badge when filter is active
|
||||
assert has_element?(view, "#payment-filter .badge")
|
||||
end
|
||||
end
|
||||
|
||||
describe "dropdown behavior" do
|
||||
test "dropdown opens on button click", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, view, _html} = live(conn, "/members")
|
||||
|
||||
# Initially dropdown is closed
|
||||
refute has_element?(view, "#payment-filter ul[role='menu']")
|
||||
|
||||
# Click to open
|
||||
view
|
||||
|> element("#payment-filter button[aria-haspopup='true']")
|
||||
|> render_click()
|
||||
|
||||
# Dropdown should be visible
|
||||
assert has_element?(view, "#payment-filter ul[role='menu']")
|
||||
end
|
||||
|
||||
test "dropdown closes after selecting an option", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, view, _html} = live(conn, "/members")
|
||||
|
||||
# Open dropdown
|
||||
view
|
||||
|> element("#payment-filter button[aria-haspopup='true']")
|
||||
|> render_click()
|
||||
|
||||
assert has_element?(view, "#payment-filter ul[role='menu']")
|
||||
|
||||
# Select an option - this should close the dropdown
|
||||
view
|
||||
|> element("#payment-filter button[phx-value-filter='paid']")
|
||||
|> render_click()
|
||||
|
||||
# After selection, dropdown should be closed
|
||||
# Note: The dropdown closes via assign, which is reflected in the next render
|
||||
refute has_element?(view, "#payment-filter ul[role='menu']")
|
||||
end
|
||||
end
|
||||
|
||||
describe "filter selection" do
|
||||
test "selecting 'All' clears the filter and updates URL", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, view, _html} = live(conn, "/members?paid_filter=paid")
|
||||
|
||||
# Open dropdown
|
||||
view
|
||||
|> element("#payment-filter button[aria-haspopup='true']")
|
||||
|> render_click()
|
||||
|
||||
# Select "All" option
|
||||
view
|
||||
|> element("#payment-filter button[phx-value-filter='']")
|
||||
|> render_click()
|
||||
|
||||
# URL should not contain paid_filter param - wait for patch
|
||||
assert_patch(view)
|
||||
end
|
||||
|
||||
test "selecting 'Paid' sets the filter and updates URL", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, view, _html} = live(conn, "/members")
|
||||
|
||||
# Open dropdown
|
||||
view
|
||||
|> element("#payment-filter button[aria-haspopup='true']")
|
||||
|> render_click()
|
||||
|
||||
# Select "Paid" option
|
||||
view
|
||||
|> element("#payment-filter button[phx-value-filter='paid']")
|
||||
|> render_click()
|
||||
|
||||
# Wait for patch and check URL contains paid_filter=paid
|
||||
path = assert_patch(view)
|
||||
assert path =~ "paid_filter=paid"
|
||||
end
|
||||
|
||||
test "selecting 'Not paid' sets the filter and updates URL", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, view, _html} = live(conn, "/members")
|
||||
|
||||
# Open dropdown
|
||||
view
|
||||
|> element("#payment-filter button[aria-haspopup='true']")
|
||||
|> render_click()
|
||||
|
||||
# Select "Not paid" option
|
||||
view
|
||||
|> element("#payment-filter button[phx-value-filter='not_paid']")
|
||||
|> render_click()
|
||||
|
||||
# Wait for patch and check URL contains paid_filter=not_paid
|
||||
path = assert_patch(view)
|
||||
assert path =~ "paid_filter=not_paid"
|
||||
end
|
||||
end
|
||||
|
||||
describe "accessibility" do
|
||||
test "has correct ARIA attributes", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, view, html} = live(conn, "/members")
|
||||
|
||||
# Main button should have aria-haspopup and aria-expanded
|
||||
assert html =~ ~s(aria-haspopup="true")
|
||||
assert html =~ ~s(aria-expanded="false")
|
||||
assert html =~ ~s(aria-label=)
|
||||
|
||||
# Open dropdown
|
||||
view
|
||||
|> element("#payment-filter button[aria-haspopup='true']")
|
||||
|> render_click()
|
||||
|
||||
html = render(view)
|
||||
|
||||
# Check aria-expanded is now true
|
||||
assert html =~ ~s(aria-expanded="true")
|
||||
|
||||
# Menu should have role="menu"
|
||||
assert html =~ ~s(role="menu")
|
||||
|
||||
# Options should have role="menuitemradio"
|
||||
assert html =~ ~s(role="menuitemradio")
|
||||
end
|
||||
|
||||
test "has aria-checked on selected option", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, view, _html} = live(conn, "/members?paid_filter=paid")
|
||||
|
||||
# Open dropdown
|
||||
view
|
||||
|> element("#payment-filter button[aria-haspopup='true']")
|
||||
|> render_click()
|
||||
|
||||
html = render(view)
|
||||
|
||||
# "Paid" option should have aria-checked="true"
|
||||
# Check both possible orderings of attributes
|
||||
assert html =~ "aria-checked=\"true\"" and html =~ "phx-value-filter=\"paid\""
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -150,35 +150,27 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
|
|||
assert has_element?(view, "[data-testid='email'] .opacity-40")
|
||||
end
|
||||
|
||||
test "icon distribution is correct for all fields", %{conn: conn} do
|
||||
test "icon distribution shows exactly one active sort icon", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
|
||||
# Test neutral state - all fields except first name (default) should show neutral icons
|
||||
# Test neutral state - only one field should have active sort icon
|
||||
{: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)
|
||||
# Count active icons (should be exactly 1 - ascending for default sort field)
|
||||
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")
|
||||
assert up_count == 1, "Expected exactly 1 ascending icon, got #{up_count}"
|
||||
assert down_count == 0, "Expected 0 descending icons, got #{down_count}"
|
||||
|
||||
# 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)
|
||||
# Test descending state
|
||||
{:ok, _view, html_desc} = live(conn, "/members?sort_field=first_name&sort_order=desc")
|
||||
|
||||
assert up_count == 1
|
||||
assert neutral_count == 7
|
||||
assert down_count == 0
|
||||
up_count = html_desc |> String.split("hero-chevron-up ") |> length() |> Kernel.-(1)
|
||||
down_count = html_desc |> String.split("hero-chevron-down ") |> length() |> Kernel.-(1)
|
||||
|
||||
assert up_count == 0, "Expected 0 ascending icons, got #{up_count}"
|
||||
assert down_count == 1, "Expected exactly 1 descending icon, got #{down_count}"
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
defmodule MvWeb.PageControllerTest do
|
||||
use MvWeb.ConnCase
|
||||
use MvWeb.ConnCase, async: true
|
||||
|
||||
test "GET /", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
test "renders home template successfully with authenticated user", %{conn: conn} do
|
||||
user = create_test_user(%{email: "test@example.com"})
|
||||
conn = conn_with_oidc_user(conn, user)
|
||||
conn = get(conn, "/")
|
||||
|
||||
conn = get(conn, ~p"/")
|
||||
assert html_response(conn, 200) =~ "Mitgliederverwaltung"
|
||||
assert html_response(conn, 200)
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
defmodule MvWeb.CustomFieldLive.DeletionTest do
|
||||
@moduledoc """
|
||||
Tests for CustomFieldLive.Index deletion modal and slug confirmation.
|
||||
Tests for CustomFieldLive.IndexComponent deletion modal and slug confirmation.
|
||||
Tests the custom field management component embedded in the settings page.
|
||||
|
||||
Tests cover:
|
||||
- Opening deletion confirmation modal
|
||||
|
|
@ -39,11 +40,11 @@ defmodule MvWeb.CustomFieldLive.DeletionTest do
|
|||
# Create custom field value
|
||||
create_custom_field_value(member, custom_field, "test")
|
||||
|
||||
{:ok, view, _html} = live(conn, ~p"/custom_fields")
|
||||
{:ok, view, _html} = live(conn, ~p"/settings")
|
||||
|
||||
# Click delete button
|
||||
# Click delete button - find the delete link within the component
|
||||
view
|
||||
|> element("a", "Delete")
|
||||
|> element("#custom-fields-component a", "Delete")
|
||||
|> render_click()
|
||||
|
||||
# Modal should be visible
|
||||
|
|
@ -65,10 +66,10 @@ defmodule MvWeb.CustomFieldLive.DeletionTest do
|
|||
create_custom_field_value(member1, custom_field, "test1")
|
||||
create_custom_field_value(member2, custom_field, "test2")
|
||||
|
||||
{:ok, view, _html} = live(conn, ~p"/custom_fields")
|
||||
{:ok, view, _html} = live(conn, ~p"/settings")
|
||||
|
||||
view
|
||||
|> element("a", "Delete")
|
||||
|> element("#custom-fields-component a", "Delete")
|
||||
|> render_click()
|
||||
|
||||
# Should show plural form
|
||||
|
|
@ -78,10 +79,10 @@ defmodule MvWeb.CustomFieldLive.DeletionTest do
|
|||
test "shows 0 members for custom field without values", %{conn: conn} do
|
||||
{:ok, _custom_field} = create_custom_field("test_field", :string)
|
||||
|
||||
{:ok, view, _html} = live(conn, ~p"/custom_fields")
|
||||
{:ok, view, _html} = live(conn, ~p"/settings")
|
||||
|
||||
view
|
||||
|> element("a", "Delete")
|
||||
|> element("#custom-fields-component a", "Delete")
|
||||
|> render_click()
|
||||
|
||||
# Should show 0 members
|
||||
|
|
@ -93,15 +94,16 @@ defmodule MvWeb.CustomFieldLive.DeletionTest do
|
|||
test "updates confirmation state when typing", %{conn: conn} do
|
||||
{:ok, custom_field} = create_custom_field("test_field", :string)
|
||||
|
||||
{:ok, view, _html} = live(conn, ~p"/custom_fields")
|
||||
{:ok, view, _html} = live(conn, ~p"/settings")
|
||||
|
||||
view
|
||||
|> element("a", "Delete")
|
||||
|> element("#custom-fields-component a", "Delete")
|
||||
|> render_click()
|
||||
|
||||
# Type in slug input
|
||||
# Type in slug input - use element to find the form with phx-target
|
||||
view
|
||||
|> render_change("update_slug_confirmation", %{"slug" => custom_field.slug})
|
||||
|> element("#delete-custom-field-modal form")
|
||||
|> render_change(%{"slug" => custom_field.slug})
|
||||
|
||||
# Confirm button should be enabled now (no disabled attribute)
|
||||
html = render(view)
|
||||
|
|
@ -111,15 +113,16 @@ defmodule MvWeb.CustomFieldLive.DeletionTest do
|
|||
test "delete button is disabled when slug doesn't match", %{conn: conn} do
|
||||
{:ok, _custom_field} = create_custom_field("test_field", :string)
|
||||
|
||||
{:ok, view, _html} = live(conn, ~p"/custom_fields")
|
||||
{:ok, view, _html} = live(conn, ~p"/settings")
|
||||
|
||||
view
|
||||
|> element("a", "Delete")
|
||||
|> element("#custom-fields-component a", "Delete")
|
||||
|> render_click()
|
||||
|
||||
# Type wrong slug
|
||||
# Type wrong slug - use element to find the form with phx-target
|
||||
view
|
||||
|> render_change("update_slug_confirmation", %{"slug" => "wrong-slug"})
|
||||
|> element("#delete-custom-field-modal form")
|
||||
|> render_change(%{"slug" => "wrong-slug"})
|
||||
|
||||
# Button should be disabled
|
||||
html = render(view)
|
||||
|
|
@ -133,20 +136,21 @@ defmodule MvWeb.CustomFieldLive.DeletionTest do
|
|||
{:ok, custom_field} = create_custom_field("test_field", :string)
|
||||
{:ok, custom_field_value} = create_custom_field_value(member, custom_field, "test")
|
||||
|
||||
{:ok, view, _html} = live(conn, ~p"/custom_fields")
|
||||
{:ok, view, _html} = live(conn, ~p"/settings")
|
||||
|
||||
# Open modal
|
||||
view
|
||||
|> element("a", "Delete")
|
||||
|> element("#custom-fields-component a", "Delete")
|
||||
|> render_click()
|
||||
|
||||
# Enter correct slug
|
||||
# Enter correct slug - use element to find the form with phx-target
|
||||
view
|
||||
|> render_change("update_slug_confirmation", %{"slug" => custom_field.slug})
|
||||
|> element("#delete-custom-field-modal form")
|
||||
|> render_change(%{"slug" => custom_field.slug})
|
||||
|
||||
# Click confirm
|
||||
view
|
||||
|> element("button", "Delete Custom Field and All Values")
|
||||
|> element("#delete-custom-field-modal button", "Delete Custom Field and All Values")
|
||||
|> render_click()
|
||||
|
||||
# Should show success message
|
||||
|
|
@ -162,27 +166,28 @@ defmodule MvWeb.CustomFieldLive.DeletionTest do
|
|||
assert {:ok, _} = Ash.get(Member, member.id)
|
||||
end
|
||||
|
||||
test "shows error when slug doesn't match", %{conn: conn} do
|
||||
test "button remains disabled and custom field not deleted when slug doesn't match", %{
|
||||
conn: conn
|
||||
} do
|
||||
{:ok, custom_field} = create_custom_field("test_field", :string)
|
||||
|
||||
{:ok, view, _html} = live(conn, ~p"/custom_fields")
|
||||
{:ok, view, _html} = live(conn, ~p"/settings")
|
||||
|
||||
view
|
||||
|> element("a", "Delete")
|
||||
|> element("#custom-fields-component a", "Delete")
|
||||
|> render_click()
|
||||
|
||||
# Enter wrong slug
|
||||
# Enter wrong slug - use element to find the form with phx-target
|
||||
view
|
||||
|> render_change("update_slug_confirmation", %{"slug" => "wrong-slug"})
|
||||
|> element("#delete-custom-field-modal form")
|
||||
|> render_change(%{"slug" => "wrong-slug"})
|
||||
|
||||
# Try to confirm (button should be disabled, but test the handler anyway)
|
||||
view
|
||||
|> render_click("confirm_delete", %{})
|
||||
# Button should be disabled and we cannot click it
|
||||
# The test verifies that the button is properly disabled in the UI
|
||||
html = render(view)
|
||||
assert html =~ ~r/disabled(?:=""|(?!\w))/
|
||||
|
||||
# Should show error message
|
||||
assert render(view) =~ "Slug does not match"
|
||||
|
||||
# Custom field should still exist
|
||||
# Custom field should still exist since deletion couldn't proceed
|
||||
assert {:ok, _} = Ash.get(CustomField, custom_field.id)
|
||||
end
|
||||
end
|
||||
|
|
@ -191,10 +196,10 @@ defmodule MvWeb.CustomFieldLive.DeletionTest do
|
|||
test "closes modal without deleting", %{conn: conn} do
|
||||
{:ok, custom_field} = create_custom_field("test_field", :string)
|
||||
|
||||
{:ok, view, _html} = live(conn, ~p"/custom_fields")
|
||||
{:ok, view, _html} = live(conn, ~p"/settings")
|
||||
|
||||
view
|
||||
|> element("a", "Delete")
|
||||
|> element("#custom-fields-component a", "Delete")
|
||||
|> render_click()
|
||||
|
||||
# Modal should be visible
|
||||
|
|
@ -202,7 +207,7 @@ defmodule MvWeb.CustomFieldLive.DeletionTest do
|
|||
|
||||
# Click cancel
|
||||
view
|
||||
|> element("button", "Cancel")
|
||||
|> element("#delete-custom-field-modal button", "Cancel")
|
||||
|> render_click()
|
||||
|
||||
# Modal should be gone
|
||||
|
|
|
|||
68
test/mv_web/live/global_settings_live_test.exs
Normal file
68
test/mv_web/live/global_settings_live_test.exs
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
defmodule MvWeb.GlobalSettingsLiveTest do
|
||||
use MvWeb.ConnCase, async: true
|
||||
import Phoenix.LiveViewTest
|
||||
alias Mv.Membership
|
||||
|
||||
describe "Global Settings LiveView" do
|
||||
setup %{conn: conn} do
|
||||
user = create_test_user(%{email: "admin@example.com"})
|
||||
conn = conn_with_oidc_user(conn, user)
|
||||
{:ok, conn: conn, user: user}
|
||||
end
|
||||
|
||||
test "renders the global settings page", %{conn: conn} do
|
||||
{:ok, _view, html} = live(conn, ~p"/settings")
|
||||
|
||||
assert html =~ "Club Settings"
|
||||
assert html =~ "Settings"
|
||||
end
|
||||
|
||||
test "displays current club name", %{conn: conn} do
|
||||
# Set initial club name
|
||||
{:ok, settings} = Membership.get_settings()
|
||||
{:ok, _updated} = Membership.update_settings(settings, %{club_name: "Test Club"})
|
||||
|
||||
{:ok, _view, html} = live(conn, ~p"/settings")
|
||||
|
||||
assert html =~ "Test Club"
|
||||
end
|
||||
|
||||
test "can update club name via form", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/settings")
|
||||
|
||||
# Submit form with new club name
|
||||
assert view
|
||||
|> form("#settings-form", %{setting: %{club_name: "Updated Club Name"}})
|
||||
|> render_submit()
|
||||
|
||||
# Check for success message
|
||||
assert render(view) =~ "Settings updated successfully"
|
||||
assert render(view) =~ "Updated Club Name"
|
||||
end
|
||||
|
||||
test "shows error when club_name is empty", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/settings")
|
||||
|
||||
# Submit form with empty club name
|
||||
html =
|
||||
view
|
||||
|> form("#settings-form", %{setting: %{club_name: ""}})
|
||||
|> render_submit()
|
||||
|
||||
assert html =~ "must be present"
|
||||
end
|
||||
|
||||
test "shows error when club_name is missing", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/settings")
|
||||
|
||||
# Submit form with club_name explicitly set to empty string
|
||||
# (Phoenix forms will keep existing value if field is omitted)
|
||||
html =
|
||||
view
|
||||
|> form("#settings-form", %{setting: %{club_name: ""}})
|
||||
|> render_submit()
|
||||
|
||||
assert html =~ "must be present"
|
||||
end
|
||||
end
|
||||
end
|
||||
370
test/mv_web/live/member_live/index/field_selection_test.exs
Normal file
370
test/mv_web/live/member_live/index/field_selection_test.exs
Normal file
|
|
@ -0,0 +1,370 @@
|
|||
defmodule MvWeb.MemberLive.Index.FieldSelectionTest do
|
||||
@moduledoc """
|
||||
Tests for FieldSelection module handling cookie/session/URL management.
|
||||
"""
|
||||
use ExUnit.Case, async: true
|
||||
|
||||
alias MvWeb.MemberLive.Index.FieldSelection
|
||||
|
||||
describe "get_from_session/1" do
|
||||
test "returns empty map when session is empty" do
|
||||
assert FieldSelection.get_from_session(%{}) == %{}
|
||||
end
|
||||
|
||||
test "returns empty map when session key is missing" do
|
||||
session = %{"other_key" => "value"}
|
||||
assert FieldSelection.get_from_session(session) == %{}
|
||||
end
|
||||
|
||||
test "parses valid JSON from session" do
|
||||
json = Jason.encode!(%{"first_name" => true, "email" => false})
|
||||
session = %{"member_field_selection" => json}
|
||||
|
||||
result = FieldSelection.get_from_session(session)
|
||||
|
||||
assert result == %{"first_name" => true, "email" => false}
|
||||
end
|
||||
|
||||
test "handles invalid JSON gracefully" do
|
||||
session = %{"member_field_selection" => "invalid json{["}
|
||||
|
||||
result = FieldSelection.get_from_session(session)
|
||||
|
||||
assert result == %{}
|
||||
end
|
||||
|
||||
test "converts non-boolean values to true" do
|
||||
json = Jason.encode!(%{"first_name" => "true", "email" => 1, "street" => true})
|
||||
session = %{"member_field_selection" => json}
|
||||
|
||||
result = FieldSelection.get_from_session(session)
|
||||
|
||||
# All values should be booleans, non-booleans default to true
|
||||
assert result["first_name"] == true
|
||||
assert result["email"] == true
|
||||
assert result["street"] == true
|
||||
end
|
||||
|
||||
test "handles nil session" do
|
||||
assert FieldSelection.get_from_session(nil) == %{}
|
||||
end
|
||||
|
||||
test "handles non-map session" do
|
||||
assert FieldSelection.get_from_session("not a map") == %{}
|
||||
end
|
||||
end
|
||||
|
||||
describe "save_to_session/2" do
|
||||
test "saves field selection to session as JSON" do
|
||||
session = %{}
|
||||
selection = %{"first_name" => true, "email" => false}
|
||||
|
||||
result = FieldSelection.save_to_session(session, selection)
|
||||
|
||||
assert Map.has_key?(result, "member_field_selection")
|
||||
assert Jason.decode!(result["member_field_selection"]) == selection
|
||||
end
|
||||
|
||||
test "overwrites existing selection" do
|
||||
session = %{"member_field_selection" => Jason.encode!(%{"old" => true})}
|
||||
selection = %{"new" => true}
|
||||
|
||||
result = FieldSelection.save_to_session(session, selection)
|
||||
|
||||
assert Jason.decode!(result["member_field_selection"]) == selection
|
||||
end
|
||||
|
||||
test "handles empty selection" do
|
||||
session = %{}
|
||||
selection = %{}
|
||||
|
||||
result = FieldSelection.save_to_session(session, selection)
|
||||
|
||||
assert Jason.decode!(result["member_field_selection"]) == %{}
|
||||
end
|
||||
|
||||
test "handles invalid selection gracefully" do
|
||||
session = %{}
|
||||
|
||||
result = FieldSelection.save_to_session(session, "not a map")
|
||||
|
||||
assert result == session
|
||||
end
|
||||
end
|
||||
|
||||
describe "get_from_cookie/1" do
|
||||
test "returns empty map when cookie header is missing" do
|
||||
conn = %Plug.Conn{}
|
||||
|
||||
result = FieldSelection.get_from_cookie(conn)
|
||||
|
||||
assert result == %{}
|
||||
end
|
||||
|
||||
test "returns empty map when cookie is empty string" do
|
||||
conn = Plug.Conn.put_req_header(%Plug.Conn{}, "cookie", "")
|
||||
|
||||
result = FieldSelection.get_from_cookie(conn)
|
||||
|
||||
assert result == %{}
|
||||
end
|
||||
|
||||
test "parses valid JSON from cookie" do
|
||||
selection = %{"first_name" => true, "email" => false}
|
||||
cookie_value = selection |> Jason.encode!() |> URI.encode()
|
||||
cookie_header = "member_field_selection=#{cookie_value}"
|
||||
conn = %Plug.Conn{} |> Plug.Conn.put_req_header("cookie", cookie_header)
|
||||
|
||||
result = FieldSelection.get_from_cookie(conn)
|
||||
|
||||
assert result == selection
|
||||
end
|
||||
|
||||
test "handles invalid JSON in cookie gracefully" do
|
||||
cookie_value = URI.encode("invalid{[")
|
||||
cookie_header = "member_field_selection=#{cookie_value}"
|
||||
conn = %Plug.Conn{} |> Plug.Conn.put_req_header("cookie", cookie_header)
|
||||
|
||||
result = FieldSelection.get_from_cookie(conn)
|
||||
|
||||
assert result == %{}
|
||||
end
|
||||
|
||||
test "handles cookie with other values" do
|
||||
selection = %{"street" => true}
|
||||
cookie_value = selection |> Jason.encode!() |> URI.encode()
|
||||
cookie_header = "other_cookie=value; member_field_selection=#{cookie_value}; another=test"
|
||||
conn = %Plug.Conn{} |> Plug.Conn.put_req_header("cookie", cookie_header)
|
||||
|
||||
result = FieldSelection.get_from_cookie(conn)
|
||||
|
||||
assert result == selection
|
||||
end
|
||||
end
|
||||
|
||||
describe "save_to_cookie/2" do
|
||||
test "saves field selection to cookie" do
|
||||
conn = %Plug.Conn{}
|
||||
selection = %{"first_name" => true, "email" => false}
|
||||
|
||||
result = FieldSelection.save_to_cookie(conn, selection)
|
||||
|
||||
# Check that cookie is set
|
||||
assert result.resp_cookies["member_field_selection"]
|
||||
cookie = result.resp_cookies["member_field_selection"]
|
||||
assert cookie[:max_age] == 365 * 24 * 60 * 60
|
||||
assert cookie[:same_site] == "Lax"
|
||||
assert cookie[:http_only] == true
|
||||
end
|
||||
|
||||
test "handles invalid selection gracefully" do
|
||||
conn = %Plug.Conn{}
|
||||
|
||||
result = FieldSelection.save_to_cookie(conn, "not a map")
|
||||
|
||||
assert result == conn
|
||||
end
|
||||
end
|
||||
|
||||
describe "parse_from_url/1" do
|
||||
test "returns empty map when params is empty" do
|
||||
assert FieldSelection.parse_from_url(%{}) == %{}
|
||||
end
|
||||
|
||||
test "returns empty map when fields parameter is missing" do
|
||||
params = %{"query" => "test", "sort_field" => "first_name"}
|
||||
assert FieldSelection.parse_from_url(params) == %{}
|
||||
end
|
||||
|
||||
test "parses comma-separated field names" do
|
||||
params = %{"fields" => "first_name,email,street"}
|
||||
|
||||
result = FieldSelection.parse_from_url(params)
|
||||
|
||||
assert result == %{
|
||||
"first_name" => true,
|
||||
"email" => true,
|
||||
"street" => true
|
||||
}
|
||||
end
|
||||
|
||||
test "handles custom field names" do
|
||||
params = %{"fields" => "custom_field_abc-123,custom_field_def-456"}
|
||||
|
||||
result = FieldSelection.parse_from_url(params)
|
||||
|
||||
assert result == %{
|
||||
"custom_field_abc-123" => true,
|
||||
"custom_field_def-456" => true
|
||||
}
|
||||
end
|
||||
|
||||
test "handles mixed member and custom fields" do
|
||||
params = %{"fields" => "first_name,custom_field_123,email"}
|
||||
|
||||
result = FieldSelection.parse_from_url(params)
|
||||
|
||||
assert result == %{
|
||||
"first_name" => true,
|
||||
"custom_field_123" => true,
|
||||
"email" => true
|
||||
}
|
||||
end
|
||||
|
||||
test "trims whitespace from field names" do
|
||||
params = %{"fields" => " first_name , email , street "}
|
||||
|
||||
result = FieldSelection.parse_from_url(params)
|
||||
|
||||
assert result == %{
|
||||
"first_name" => true,
|
||||
"email" => true,
|
||||
"street" => true
|
||||
}
|
||||
end
|
||||
|
||||
test "handles empty fields string" do
|
||||
params = %{"fields" => ""}
|
||||
assert FieldSelection.parse_from_url(params) == %{}
|
||||
end
|
||||
|
||||
test "handles nil fields parameter" do
|
||||
params = %{"fields" => nil}
|
||||
assert FieldSelection.parse_from_url(params) == %{}
|
||||
end
|
||||
|
||||
test "filters out empty field names" do
|
||||
params = %{"fields" => "first_name,,email,"}
|
||||
|
||||
result = FieldSelection.parse_from_url(params)
|
||||
|
||||
assert result == %{
|
||||
"first_name" => true,
|
||||
"email" => true
|
||||
}
|
||||
end
|
||||
|
||||
test "handles non-map params" do
|
||||
assert FieldSelection.parse_from_url(nil) == %{}
|
||||
assert FieldSelection.parse_from_url("not a map") == %{}
|
||||
end
|
||||
end
|
||||
|
||||
describe "merge_sources/3" do
|
||||
test "merges all sources with URL having highest priority" do
|
||||
url_selection = %{"first_name" => false}
|
||||
session_selection = %{"first_name" => true, "email" => true}
|
||||
cookie_selection = %{"first_name" => true, "street" => true}
|
||||
|
||||
result = FieldSelection.merge_sources(url_selection, session_selection, cookie_selection)
|
||||
|
||||
# URL overrides session, session overrides cookie
|
||||
assert result["first_name"] == false
|
||||
assert result["email"] == true
|
||||
assert result["street"] == true
|
||||
end
|
||||
|
||||
test "handles empty sources" do
|
||||
result = FieldSelection.merge_sources(%{}, %{}, %{})
|
||||
|
||||
assert result == %{}
|
||||
end
|
||||
|
||||
test "cookie only" do
|
||||
cookie_selection = %{"first_name" => true}
|
||||
|
||||
result = FieldSelection.merge_sources(%{}, %{}, cookie_selection)
|
||||
|
||||
assert result == %{"first_name" => true}
|
||||
end
|
||||
|
||||
test "session overrides cookie" do
|
||||
session_selection = %{"first_name" => false}
|
||||
cookie_selection = %{"first_name" => true}
|
||||
|
||||
result = FieldSelection.merge_sources(%{}, session_selection, cookie_selection)
|
||||
|
||||
assert result["first_name"] == false
|
||||
end
|
||||
|
||||
test "URL overrides everything" do
|
||||
url_selection = %{"first_name" => true}
|
||||
session_selection = %{"first_name" => false}
|
||||
cookie_selection = %{"first_name" => false}
|
||||
|
||||
result = FieldSelection.merge_sources(url_selection, session_selection, cookie_selection)
|
||||
|
||||
assert result["first_name"] == true
|
||||
end
|
||||
|
||||
test "combines fields from all sources" do
|
||||
url_selection = %{"url_field" => true}
|
||||
session_selection = %{"session_field" => true}
|
||||
cookie_selection = %{"cookie_field" => true}
|
||||
|
||||
result = FieldSelection.merge_sources(url_selection, session_selection, cookie_selection)
|
||||
|
||||
assert result["url_field"] == true
|
||||
assert result["session_field"] == true
|
||||
assert result["cookie_field"] == true
|
||||
end
|
||||
end
|
||||
|
||||
describe "to_url_param/1" do
|
||||
test "converts selection to comma-separated string" do
|
||||
selection = %{"first_name" => true, "email" => true, "street" => false}
|
||||
|
||||
result = FieldSelection.to_url_param(selection)
|
||||
|
||||
# Only visible fields should be included (order may vary)
|
||||
fields = String.split(result, ",") |> Enum.sort()
|
||||
assert fields == ["email", "first_name"]
|
||||
end
|
||||
|
||||
test "handles empty selection" do
|
||||
assert FieldSelection.to_url_param(%{}) == ""
|
||||
end
|
||||
|
||||
test "handles all fields hidden" do
|
||||
selection = %{"first_name" => false, "email" => false}
|
||||
|
||||
result = FieldSelection.to_url_param(selection)
|
||||
|
||||
assert result == ""
|
||||
end
|
||||
|
||||
test "preserves field order" do
|
||||
selection = %{
|
||||
"z_field" => true,
|
||||
"a_field" => true,
|
||||
"m_field" => true
|
||||
}
|
||||
|
||||
result = FieldSelection.to_url_param(selection)
|
||||
|
||||
# Order should be preserved (map iteration order)
|
||||
assert String.contains?(result, "z_field")
|
||||
assert String.contains?(result, "a_field")
|
||||
assert String.contains?(result, "m_field")
|
||||
end
|
||||
|
||||
test "handles custom fields" do
|
||||
selection = %{
|
||||
"first_name" => true,
|
||||
"custom_field_abc-123" => true,
|
||||
"email" => false
|
||||
}
|
||||
|
||||
result = FieldSelection.to_url_param(selection)
|
||||
|
||||
assert String.contains?(result, "first_name")
|
||||
assert String.contains?(result, "custom_field_abc-123")
|
||||
refute String.contains?(result, "email")
|
||||
end
|
||||
|
||||
test "handles invalid input" do
|
||||
assert FieldSelection.to_url_param(nil) == ""
|
||||
assert FieldSelection.to_url_param("not a map") == ""
|
||||
end
|
||||
end
|
||||
end
|
||||
336
test/mv_web/live/member_live/index/field_visibility_test.exs
Normal file
336
test/mv_web/live/member_live/index/field_visibility_test.exs
Normal file
|
|
@ -0,0 +1,336 @@
|
|||
defmodule MvWeb.MemberLive.Index.FieldVisibilityTest do
|
||||
@moduledoc """
|
||||
Tests for FieldVisibility module handling field visibility merging logic.
|
||||
"""
|
||||
use ExUnit.Case, async: true
|
||||
|
||||
alias MvWeb.MemberLive.Index.FieldVisibility
|
||||
|
||||
# Mock custom field structs for testing
|
||||
defp create_custom_field(id, name, show_in_overview \\ true) do
|
||||
%{
|
||||
id: id,
|
||||
name: name,
|
||||
show_in_overview: show_in_overview
|
||||
}
|
||||
end
|
||||
|
||||
describe "get_all_available_fields/1" do
|
||||
test "returns member fields and custom fields" do
|
||||
custom_fields = [
|
||||
create_custom_field("cf1", "Custom Field 1"),
|
||||
create_custom_field("cf2", "Custom Field 2")
|
||||
]
|
||||
|
||||
result = FieldVisibility.get_all_available_fields(custom_fields)
|
||||
|
||||
# Should include all member fields
|
||||
assert :first_name in result
|
||||
assert :email in result
|
||||
assert :street in result
|
||||
|
||||
# Should include custom fields as strings
|
||||
assert "custom_field_cf1" in result
|
||||
assert "custom_field_cf2" in result
|
||||
end
|
||||
|
||||
test "handles empty custom fields list" do
|
||||
result = FieldVisibility.get_all_available_fields([])
|
||||
|
||||
# Should only have member fields
|
||||
assert :first_name in result
|
||||
assert :email in result
|
||||
|
||||
refute Enum.any?(result, fn field ->
|
||||
is_binary(field) and String.starts_with?(field, "custom_field_")
|
||||
end)
|
||||
end
|
||||
|
||||
test "includes all member fields from constants" do
|
||||
custom_fields = []
|
||||
result = FieldVisibility.get_all_available_fields(custom_fields)
|
||||
|
||||
member_fields = Mv.Constants.member_fields()
|
||||
|
||||
Enum.each(member_fields, fn field ->
|
||||
assert field in result
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
describe "merge_with_global_settings/3" do
|
||||
test "user selection overrides global settings" do
|
||||
user_selection = %{"first_name" => false}
|
||||
settings = %{member_field_visibility: %{first_name: true, email: true}}
|
||||
custom_fields = []
|
||||
|
||||
result = FieldVisibility.merge_with_global_settings(user_selection, settings, custom_fields)
|
||||
|
||||
assert result["first_name"] == false
|
||||
assert result["email"] == true
|
||||
end
|
||||
|
||||
test "falls back to global settings when user selection is empty" do
|
||||
user_selection = %{}
|
||||
settings = %{member_field_visibility: %{first_name: false, email: true}}
|
||||
custom_fields = []
|
||||
|
||||
result = FieldVisibility.merge_with_global_settings(user_selection, settings, custom_fields)
|
||||
|
||||
assert result["first_name"] == false
|
||||
assert result["email"] == true
|
||||
end
|
||||
|
||||
test "defaults to true when field not in settings" do
|
||||
user_selection = %{}
|
||||
settings = %{member_field_visibility: %{first_name: false}}
|
||||
custom_fields = []
|
||||
|
||||
result = FieldVisibility.merge_with_global_settings(user_selection, settings, custom_fields)
|
||||
|
||||
# first_name from settings
|
||||
assert result["first_name"] == false
|
||||
# email defaults to true (not in settings)
|
||||
assert result["email"] == true
|
||||
end
|
||||
|
||||
test "handles custom fields visibility" do
|
||||
user_selection = %{}
|
||||
settings = %{member_field_visibility: %{}}
|
||||
|
||||
custom_fields = [
|
||||
create_custom_field("cf1", "Custom 1", true),
|
||||
create_custom_field("cf2", "Custom 2", false)
|
||||
]
|
||||
|
||||
result = FieldVisibility.merge_with_global_settings(user_selection, settings, custom_fields)
|
||||
|
||||
assert result["custom_field_cf1"] == true
|
||||
assert result["custom_field_cf2"] == false
|
||||
end
|
||||
|
||||
test "user selection overrides custom field visibility" do
|
||||
user_selection = %{"custom_field_cf1" => false}
|
||||
settings = %{member_field_visibility: %{}}
|
||||
|
||||
custom_fields = [
|
||||
create_custom_field("cf1", "Custom 1", true)
|
||||
]
|
||||
|
||||
result = FieldVisibility.merge_with_global_settings(user_selection, settings, custom_fields)
|
||||
|
||||
assert result["custom_field_cf1"] == false
|
||||
end
|
||||
|
||||
test "handles string keys in settings (JSONB format)" do
|
||||
user_selection = %{}
|
||||
settings = %{member_field_visibility: %{"first_name" => false, "email" => true}}
|
||||
custom_fields = []
|
||||
|
||||
result = FieldVisibility.merge_with_global_settings(user_selection, settings, custom_fields)
|
||||
|
||||
assert result["first_name"] == false
|
||||
assert result["email"] == true
|
||||
end
|
||||
|
||||
test "handles mixed atom and string keys in settings" do
|
||||
user_selection = %{}
|
||||
# Use string keys only (as JSONB would return)
|
||||
settings = %{member_field_visibility: %{"first_name" => false, "email" => true}}
|
||||
custom_fields = []
|
||||
|
||||
result = FieldVisibility.merge_with_global_settings(user_selection, settings, custom_fields)
|
||||
|
||||
assert result["first_name"] == false
|
||||
assert result["email"] == true
|
||||
end
|
||||
|
||||
test "handles nil settings gracefully" do
|
||||
user_selection = %{}
|
||||
settings = %{member_field_visibility: nil}
|
||||
custom_fields = []
|
||||
|
||||
result = FieldVisibility.merge_with_global_settings(user_selection, settings, custom_fields)
|
||||
|
||||
# Should default all fields to true
|
||||
assert result["first_name"] == true
|
||||
assert result["email"] == true
|
||||
end
|
||||
|
||||
test "handles missing member_field_visibility key" do
|
||||
user_selection = %{}
|
||||
settings = %{}
|
||||
custom_fields = []
|
||||
|
||||
result = FieldVisibility.merge_with_global_settings(user_selection, settings, custom_fields)
|
||||
|
||||
# Should default all fields to true
|
||||
assert result["first_name"] == true
|
||||
assert result["email"] == true
|
||||
end
|
||||
|
||||
test "includes all fields in result" do
|
||||
user_selection = %{"first_name" => false}
|
||||
settings = %{member_field_visibility: %{email: true}}
|
||||
|
||||
custom_fields = [
|
||||
create_custom_field("cf1", "Custom 1", true)
|
||||
]
|
||||
|
||||
result = FieldVisibility.merge_with_global_settings(user_selection, settings, custom_fields)
|
||||
|
||||
# Should include all member fields
|
||||
member_fields = Mv.Constants.member_fields()
|
||||
|
||||
Enum.each(member_fields, fn field ->
|
||||
assert Map.has_key?(result, Atom.to_string(field))
|
||||
end)
|
||||
|
||||
# Should include custom fields
|
||||
assert Map.has_key?(result, "custom_field_cf1")
|
||||
end
|
||||
end
|
||||
|
||||
describe "get_visible_fields/1" do
|
||||
test "returns only fields with true visibility" do
|
||||
selection = %{
|
||||
"first_name" => true,
|
||||
"email" => false,
|
||||
"street" => true,
|
||||
"custom_field_123" => false
|
||||
}
|
||||
|
||||
result = FieldVisibility.get_visible_fields(selection)
|
||||
|
||||
assert :first_name in result
|
||||
assert :street in result
|
||||
refute :email in result
|
||||
refute "custom_field_123" in result
|
||||
end
|
||||
|
||||
test "converts member field strings to atoms" do
|
||||
selection = %{"first_name" => true, "email" => true}
|
||||
|
||||
result = FieldVisibility.get_visible_fields(selection)
|
||||
|
||||
assert :first_name in result
|
||||
assert :email in result
|
||||
end
|
||||
|
||||
test "keeps custom fields as strings" do
|
||||
selection = %{"custom_field_abc-123" => true}
|
||||
|
||||
result = FieldVisibility.get_visible_fields(selection)
|
||||
|
||||
assert "custom_field_abc-123" in result
|
||||
end
|
||||
|
||||
test "handles empty selection" do
|
||||
assert FieldVisibility.get_visible_fields(%{}) == []
|
||||
end
|
||||
|
||||
test "handles all fields hidden" do
|
||||
selection = %{"first_name" => false, "email" => false}
|
||||
|
||||
assert FieldVisibility.get_visible_fields(selection) == []
|
||||
end
|
||||
|
||||
test "handles invalid input" do
|
||||
assert FieldVisibility.get_visible_fields(nil) == []
|
||||
end
|
||||
end
|
||||
|
||||
describe "get_visible_member_fields/1" do
|
||||
test "returns only member fields that are visible" do
|
||||
selection = %{
|
||||
"first_name" => true,
|
||||
"email" => true,
|
||||
"custom_field_123" => true,
|
||||
"street" => false
|
||||
}
|
||||
|
||||
result = FieldVisibility.get_visible_member_fields(selection)
|
||||
|
||||
assert :first_name in result
|
||||
assert :email in result
|
||||
refute :street in result
|
||||
refute "custom_field_123" in result
|
||||
end
|
||||
|
||||
test "filters out custom fields" do
|
||||
selection = %{
|
||||
"first_name" => true,
|
||||
"custom_field_123" => true,
|
||||
"custom_field_456" => true
|
||||
}
|
||||
|
||||
result = FieldVisibility.get_visible_member_fields(selection)
|
||||
|
||||
assert :first_name in result
|
||||
refute "custom_field_123" in result
|
||||
refute "custom_field_456" in result
|
||||
end
|
||||
|
||||
test "handles empty selection" do
|
||||
assert FieldVisibility.get_visible_member_fields(%{}) == []
|
||||
end
|
||||
|
||||
test "handles invalid input" do
|
||||
assert FieldVisibility.get_visible_member_fields(nil) == []
|
||||
end
|
||||
end
|
||||
|
||||
describe "get_visible_custom_fields/1" do
|
||||
test "returns only custom fields that are visible" do
|
||||
selection = %{
|
||||
"first_name" => true,
|
||||
"custom_field_123" => true,
|
||||
"custom_field_456" => false,
|
||||
"email" => true
|
||||
}
|
||||
|
||||
result = FieldVisibility.get_visible_custom_fields(selection)
|
||||
|
||||
assert "custom_field_123" in result
|
||||
refute "custom_field_456" in result
|
||||
refute :first_name in result
|
||||
refute :email in result
|
||||
end
|
||||
|
||||
test "filters out member fields" do
|
||||
selection = %{
|
||||
"first_name" => true,
|
||||
"email" => true,
|
||||
"custom_field_123" => true
|
||||
}
|
||||
|
||||
result = FieldVisibility.get_visible_custom_fields(selection)
|
||||
|
||||
assert "custom_field_123" in result
|
||||
refute :first_name in result
|
||||
refute :email in result
|
||||
end
|
||||
|
||||
test "handles empty selection" do
|
||||
assert FieldVisibility.get_visible_custom_fields(%{}) == []
|
||||
end
|
||||
|
||||
test "handles fields that look like custom fields but aren't" do
|
||||
selection = %{
|
||||
"custom_field_123" => true,
|
||||
"custom_field_like_name" => true,
|
||||
"not_custom_field" => true
|
||||
}
|
||||
|
||||
result = FieldVisibility.get_visible_custom_fields(selection)
|
||||
|
||||
assert "custom_field_123" in result
|
||||
assert "custom_field_like_name" in result
|
||||
refute "not_custom_field" in result
|
||||
end
|
||||
|
||||
test "handles invalid input" do
|
||||
assert FieldVisibility.get_visible_custom_fields(nil) == []
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -90,8 +90,6 @@ defmodule MvWeb.ProfileNavigationTest do
|
|||
# Verify we're on the correct profile page with OIDC specific information
|
||||
{:ok, _profile_view, html} = live(conn, "/users/#{user.id}")
|
||||
assert html =~ to_string(user.email)
|
||||
# OIDC ID should be visible
|
||||
assert html =~ "oidc_123"
|
||||
# Password auth should be disabled for OIDC users
|
||||
assert html =~ "Not enabled"
|
||||
end
|
||||
|
|
@ -150,8 +148,6 @@ defmodule MvWeb.ProfileNavigationTest do
|
|||
"/members/new",
|
||||
"/custom_field_values",
|
||||
"/custom_field_values/new",
|
||||
"/custom_fields",
|
||||
"/custom_fields/new",
|
||||
"/users",
|
||||
"/users/new"
|
||||
]
|
||||
|
|
|
|||
|
|
@ -0,0 +1,113 @@
|
|||
defmodule MvWeb.MemberLive.IndexCustomFieldsAccessibilityTest do
|
||||
@moduledoc """
|
||||
Accessibility tests for custom field columns in the member overview.
|
||||
|
||||
Tests cover:
|
||||
- SortHeaderComponent for custom fields has correct ARIA labels
|
||||
- Tab navigation works for custom field columns
|
||||
- Screen reader announcements for sorting
|
||||
"""
|
||||
use MvWeb.ConnCase, async: true
|
||||
import Phoenix.LiveViewTest
|
||||
require Ash.Query
|
||||
|
||||
alias Mv.Membership.{CustomField, CustomFieldValue, Member}
|
||||
|
||||
setup do
|
||||
# Create test member
|
||||
{:ok, member} =
|
||||
Member
|
||||
|> Ash.Changeset.for_create(:create_member, %{
|
||||
first_name: "Alice",
|
||||
last_name: "Anderson",
|
||||
email: "alice@example.com"
|
||||
})
|
||||
|> Ash.create()
|
||||
|
||||
# Create custom field with show_in_overview: true
|
||||
{:ok, field} =
|
||||
CustomField
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
name: "membership_number",
|
||||
value_type: :string,
|
||||
show_in_overview: true
|
||||
})
|
||||
|> Ash.create()
|
||||
|
||||
# Create custom field value
|
||||
{:ok, _cfv} =
|
||||
CustomFieldValue
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
member_id: member.id,
|
||||
custom_field_id: field.id,
|
||||
value: %{"_union_type" => "string", "_union_value" => "A001"}
|
||||
})
|
||||
|> Ash.create()
|
||||
|
||||
%{member: member, field: field}
|
||||
end
|
||||
|
||||
test "sort header component for custom fields has correct ARIA labels", %{
|
||||
conn: conn,
|
||||
field: field
|
||||
} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, _view, html} = live(conn, "/members")
|
||||
|
||||
# Check that the sort button has aria-label
|
||||
assert html =~ ~r/aria-label=["']Click to sort["']/i or
|
||||
html =~ ~r/aria-label=["'].*sort.*["']/i
|
||||
|
||||
# Check that data-testid is present for testing
|
||||
assert html =~ ~r/data-testid=["']custom_field_#{field.id}["']/
|
||||
end
|
||||
|
||||
test "sort header component shows correct ARIA label when sorted ascending", %{
|
||||
conn: conn,
|
||||
field: field
|
||||
} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
|
||||
{:ok, view, _html} =
|
||||
live(conn, "/members?query=&sort_field=custom_field_#{field.id}&sort_order=asc")
|
||||
|
||||
html = render(view)
|
||||
|
||||
# Check that aria-label indicates ascending sort
|
||||
assert html =~ ~r/aria-label=["'].*ascending.*["']/i
|
||||
end
|
||||
|
||||
test "sort header component shows correct ARIA label when sorted descending", %{
|
||||
conn: conn,
|
||||
field: field
|
||||
} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
|
||||
{:ok, view, _html} =
|
||||
live(conn, "/members?query=&sort_field=custom_field_#{field.id}&sort_order=desc")
|
||||
|
||||
html = render(view)
|
||||
|
||||
# Check that aria-label indicates descending sort
|
||||
assert html =~ ~r/aria-label=["'].*descending.*["']/i
|
||||
end
|
||||
|
||||
test "custom field column header is keyboard accessible", %{conn: conn, field: field} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, _view, html} = live(conn, "/members")
|
||||
|
||||
# Check that the sort button is a button element (keyboard accessible)
|
||||
assert html =~ ~r/<button[^>]*data-testid=["']custom_field_#{field.id}["']/
|
||||
|
||||
# Button should not have tabindex="-1" (which would remove from tab order)
|
||||
refute html =~ ~r/tabindex=["']-1["']/
|
||||
end
|
||||
|
||||
test "custom field column header has proper semantic structure", %{conn: conn, field: field} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, _view, html} = live(conn, "/members")
|
||||
|
||||
# Check that custom field name is displayed in the header
|
||||
assert html =~ field.name
|
||||
end
|
||||
end
|
||||
266
test/mv_web/member_live/index_custom_fields_display_test.exs
Normal file
266
test/mv_web/member_live/index_custom_fields_display_test.exs
Normal file
|
|
@ -0,0 +1,266 @@
|
|||
defmodule MvWeb.MemberLive.IndexCustomFieldsDisplayTest do
|
||||
@moduledoc """
|
||||
Tests for displaying custom fields in the member overview.
|
||||
|
||||
Tests cover:
|
||||
- Custom fields with show_in_overview: true are displayed
|
||||
- Custom fields with show_in_overview: false are not displayed
|
||||
- Multiple custom fields with show_in_overview: true are all displayed
|
||||
- Custom field values are correctly formatted for different types
|
||||
- Members without custom field values show empty cell or "-"
|
||||
"""
|
||||
# async: false to prevent PostgreSQL deadlocks when creating members and custom fields
|
||||
use MvWeb.ConnCase, async: false
|
||||
import Phoenix.LiveViewTest
|
||||
require Ash.Query
|
||||
|
||||
alias Mv.Membership.{CustomField, CustomFieldValue, Member}
|
||||
|
||||
setup do
|
||||
# Create test members
|
||||
{:ok, member1} =
|
||||
Member
|
||||
|> Ash.Changeset.for_create(:create_member, %{
|
||||
first_name: "Alice",
|
||||
last_name: "Anderson",
|
||||
email: "alice@example.com"
|
||||
})
|
||||
|> Ash.create()
|
||||
|
||||
{:ok, member2} =
|
||||
Member
|
||||
|> Ash.Changeset.for_create(:create_member, %{
|
||||
first_name: "Bob",
|
||||
last_name: "Brown",
|
||||
email: "bob@example.com"
|
||||
})
|
||||
|> Ash.create()
|
||||
|
||||
# Create custom fields
|
||||
{:ok, field_show_string} =
|
||||
CustomField
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
name: "phone_mobile",
|
||||
value_type: :string,
|
||||
show_in_overview: true
|
||||
})
|
||||
|> Ash.create()
|
||||
|
||||
{:ok, field_hide} =
|
||||
CustomField
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
name: "internal_note",
|
||||
value_type: :string,
|
||||
show_in_overview: false
|
||||
})
|
||||
|> Ash.create()
|
||||
|
||||
{:ok, field_show_integer} =
|
||||
CustomField
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
name: "membership_number",
|
||||
value_type: :integer,
|
||||
show_in_overview: true
|
||||
})
|
||||
|> Ash.create()
|
||||
|
||||
{:ok, field_show_boolean} =
|
||||
CustomField
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
name: "newsletter",
|
||||
value_type: :boolean,
|
||||
show_in_overview: true
|
||||
})
|
||||
|> Ash.create()
|
||||
|
||||
{:ok, field_show_date} =
|
||||
CustomField
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
name: "birthday",
|
||||
value_type: :date,
|
||||
show_in_overview: true
|
||||
})
|
||||
|> Ash.create()
|
||||
|
||||
{:ok, field_show_email} =
|
||||
CustomField
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
name: "secondary_email",
|
||||
value_type: :email,
|
||||
show_in_overview: true
|
||||
})
|
||||
|> Ash.create()
|
||||
|
||||
# Create custom field values for member1
|
||||
{:ok, _cfv1} =
|
||||
CustomFieldValue
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
member_id: member1.id,
|
||||
custom_field_id: field_show_string.id,
|
||||
value: %{"_union_type" => "string", "_union_value" => "+49123456789"}
|
||||
})
|
||||
|> Ash.create()
|
||||
|
||||
{:ok, _cfv2} =
|
||||
CustomFieldValue
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
member_id: member1.id,
|
||||
custom_field_id: field_show_integer.id,
|
||||
value: %{"_union_type" => "integer", "_union_value" => 12_345}
|
||||
})
|
||||
|> Ash.create()
|
||||
|
||||
{:ok, _cfv3} =
|
||||
CustomFieldValue
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
member_id: member1.id,
|
||||
custom_field_id: field_show_boolean.id,
|
||||
value: %{"_union_type" => "boolean", "_union_value" => true}
|
||||
})
|
||||
|> Ash.create()
|
||||
|
||||
{:ok, _cfv4} =
|
||||
CustomFieldValue
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
member_id: member1.id,
|
||||
custom_field_id: field_show_date.id,
|
||||
value: %{"_union_type" => "date", "_union_value" => ~D[1990-05-15]}
|
||||
})
|
||||
|> Ash.create()
|
||||
|
||||
{:ok, _cfv5} =
|
||||
CustomFieldValue
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
member_id: member1.id,
|
||||
custom_field_id: field_show_email.id,
|
||||
value: %{"_union_type" => "email", "_union_value" => "alice.private@example.com"}
|
||||
})
|
||||
|> Ash.create()
|
||||
|
||||
# Create hidden custom field value (should not be displayed)
|
||||
{:ok, _cfv_hidden} =
|
||||
CustomFieldValue
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
member_id: member1.id,
|
||||
custom_field_id: field_hide.id,
|
||||
value: %{"_union_type" => "string", "_union_value" => "Internal note"}
|
||||
})
|
||||
|> Ash.create()
|
||||
|
||||
%{
|
||||
member1: member1,
|
||||
member2: member2,
|
||||
field_show_string: field_show_string,
|
||||
field_hide: field_hide,
|
||||
field_show_integer: field_show_integer,
|
||||
field_show_boolean: field_show_boolean,
|
||||
field_show_date: field_show_date,
|
||||
field_show_email: field_show_email
|
||||
}
|
||||
end
|
||||
|
||||
test "displays custom field with show_in_overview: true", %{
|
||||
conn: conn,
|
||||
member1: _member1,
|
||||
field_show_string: field
|
||||
} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, _view, html} = live(conn, "/members")
|
||||
|
||||
# Check that the custom field column header is displayed
|
||||
assert html =~ field.name
|
||||
|
||||
# Check that the value is displayed
|
||||
assert html =~ "+49123456789"
|
||||
end
|
||||
|
||||
test "does not display custom field with show_in_overview: false", %{
|
||||
conn: conn,
|
||||
member1: _member1,
|
||||
field_hide: field
|
||||
} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, _view, html} = live(conn, "/members")
|
||||
|
||||
# Check that the hidden custom field column header is NOT displayed
|
||||
refute html =~ field.name
|
||||
|
||||
# Check that the value is NOT displayed
|
||||
refute html =~ "Internal note"
|
||||
end
|
||||
|
||||
test "displays multiple custom fields with show_in_overview: true", %{
|
||||
conn: conn,
|
||||
field_show_string: field_string,
|
||||
field_show_integer: field_integer,
|
||||
field_show_boolean: field_boolean
|
||||
} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, _view, html} = live(conn, "/members")
|
||||
|
||||
# Check that all visible custom field column headers are displayed
|
||||
assert html =~ field_string.name
|
||||
assert html =~ field_integer.name
|
||||
assert html =~ field_boolean.name
|
||||
end
|
||||
|
||||
test "formats string custom field values correctly", %{conn: conn, member1: _member1} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, _view, html} = live(conn, "/members")
|
||||
|
||||
assert html =~ "+49123456789"
|
||||
end
|
||||
|
||||
test "formats integer custom field values correctly", %{conn: conn, member1: _member1} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, _view, html} = live(conn, "/members")
|
||||
|
||||
assert html =~ "12345"
|
||||
end
|
||||
|
||||
test "formats boolean custom field values correctly", %{conn: conn, member1: _member1} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, _view, html} = live(conn, "/members")
|
||||
|
||||
# Boolean should be displayed as "Yes" or "No" or similar
|
||||
# Check for true representation
|
||||
assert html =~ "true" or html =~ "Yes" or html =~ "Ja"
|
||||
end
|
||||
|
||||
test "formats date custom field values correctly", %{conn: conn, member1: _member1} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, _view, html} = live(conn, "/members")
|
||||
|
||||
# Date should be displayed in European format (dd.mm.yyyy)
|
||||
assert html =~ "15.05.1990"
|
||||
end
|
||||
|
||||
test "formats email custom field values correctly", %{conn: conn, member1: _member1} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, _view, html} = live(conn, "/members")
|
||||
|
||||
assert html =~ "alice.private@example.com"
|
||||
end
|
||||
|
||||
test "shows empty cell for members without custom field values", %{
|
||||
conn: conn,
|
||||
member2: _member2,
|
||||
field_show_string: field
|
||||
} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, _view, html} = live(conn, "/members")
|
||||
|
||||
# The custom field column should exist
|
||||
assert html =~ field.name
|
||||
|
||||
# Member2 should exist in the table (first_name and last_name are in separate columns)
|
||||
assert html =~ "Bob"
|
||||
assert html =~ "Brown"
|
||||
|
||||
# The value from member1 should appear (phone number)
|
||||
assert html =~ "+49123456789"
|
||||
|
||||
# Note: Member2 doesn't have this custom field value, so the cell is empty
|
||||
# The implementation shows "" for missing values, which is the expected behavior
|
||||
end
|
||||
end
|
||||
173
test/mv_web/member_live/index_custom_fields_edge_cases_test.exs
Normal file
173
test/mv_web/member_live/index_custom_fields_edge_cases_test.exs
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
defmodule MvWeb.MemberLive.IndexCustomFieldsEdgeCasesTest do
|
||||
@moduledoc """
|
||||
Edge case tests for custom fields in the member overview.
|
||||
|
||||
Tests cover:
|
||||
- Custom field without values (all members have no value)
|
||||
- Very long custom field values are correctly displayed
|
||||
"""
|
||||
use MvWeb.ConnCase, async: true
|
||||
import Phoenix.LiveViewTest
|
||||
require Ash.Query
|
||||
|
||||
alias Mv.Membership.{CustomField, Member}
|
||||
|
||||
test "displays custom field column even when no members have values", %{conn: conn} do
|
||||
# Create test members without custom field values
|
||||
{:ok, _member1} =
|
||||
Member
|
||||
|> Ash.Changeset.for_create(:create_member, %{
|
||||
first_name: "Alice",
|
||||
last_name: "Anderson",
|
||||
email: "alice@example.com"
|
||||
})
|
||||
|> Ash.create()
|
||||
|
||||
{:ok, _member2} =
|
||||
Member
|
||||
|> Ash.Changeset.for_create(:create_member, %{
|
||||
first_name: "Bob",
|
||||
last_name: "Brown",
|
||||
email: "bob@example.com"
|
||||
})
|
||||
|> Ash.create()
|
||||
|
||||
# Create custom field with show_in_overview: true but no values
|
||||
{:ok, field} =
|
||||
CustomField
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
name: "membership_number",
|
||||
value_type: :string,
|
||||
show_in_overview: true
|
||||
})
|
||||
|> Ash.create()
|
||||
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, _view, html} = live(conn, "/members")
|
||||
|
||||
# Check that the custom field column header is still displayed
|
||||
assert html =~ field.name
|
||||
end
|
||||
|
||||
test "displays very long custom field values correctly", %{conn: conn} do
|
||||
# Create test member
|
||||
{:ok, member} =
|
||||
Member
|
||||
|> Ash.Changeset.for_create(:create_member, %{
|
||||
first_name: "Alice",
|
||||
last_name: "Anderson",
|
||||
email: "alice@example.com"
|
||||
})
|
||||
|> Ash.create()
|
||||
|
||||
# Create custom field
|
||||
{:ok, field} =
|
||||
CustomField
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
name: "long_note",
|
||||
value_type: :string,
|
||||
show_in_overview: true
|
||||
})
|
||||
|> Ash.create()
|
||||
|
||||
# Create very long value (but within limits)
|
||||
long_value = String.duplicate("A", 500)
|
||||
|
||||
{:ok, _cfv} =
|
||||
Mv.Membership.CustomFieldValue
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
member_id: member.id,
|
||||
custom_field_id: field.id,
|
||||
value: %{"_union_type" => "string", "_union_value" => long_value}
|
||||
})
|
||||
|> Ash.create()
|
||||
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, _view, html} = live(conn, "/members")
|
||||
|
||||
# Check that the value is displayed (may be truncated in UI, but should be present)
|
||||
# We check for at least part of the value
|
||||
assert html =~ "A" or html =~ long_value
|
||||
end
|
||||
|
||||
test "handles multiple custom fields with show_in_overview correctly", %{conn: conn} do
|
||||
# Create test member
|
||||
{:ok, member} =
|
||||
Member
|
||||
|> Ash.Changeset.for_create(:create_member, %{
|
||||
first_name: "Alice",
|
||||
last_name: "Anderson",
|
||||
email: "alice@example.com"
|
||||
})
|
||||
|> Ash.create()
|
||||
|
||||
# Create multiple custom fields with show_in_overview: true
|
||||
{:ok, field1} =
|
||||
CustomField
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
name: "field1",
|
||||
value_type: :string,
|
||||
show_in_overview: true
|
||||
})
|
||||
|> Ash.create()
|
||||
|
||||
{:ok, field2} =
|
||||
CustomField
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
name: "field2",
|
||||
value_type: :string,
|
||||
show_in_overview: true
|
||||
})
|
||||
|> Ash.create()
|
||||
|
||||
{:ok, field3} =
|
||||
CustomField
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
name: "field3",
|
||||
value_type: :string,
|
||||
show_in_overview: true
|
||||
})
|
||||
|> Ash.create()
|
||||
|
||||
# Create values for all fields
|
||||
{:ok, _cfv1} =
|
||||
Mv.Membership.CustomFieldValue
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
member_id: member.id,
|
||||
custom_field_id: field1.id,
|
||||
value: %{"_union_type" => "string", "_union_value" => "Value1"}
|
||||
})
|
||||
|> Ash.create()
|
||||
|
||||
{:ok, _cfv2} =
|
||||
Mv.Membership.CustomFieldValue
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
member_id: member.id,
|
||||
custom_field_id: field2.id,
|
||||
value: %{"_union_type" => "string", "_union_value" => "Value2"}
|
||||
})
|
||||
|> Ash.create()
|
||||
|
||||
{:ok, _cfv3} =
|
||||
Mv.Membership.CustomFieldValue
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
member_id: member.id,
|
||||
custom_field_id: field3.id,
|
||||
value: %{"_union_type" => "string", "_union_value" => "Value3"}
|
||||
})
|
||||
|> Ash.create()
|
||||
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, _view, html} = live(conn, "/members")
|
||||
|
||||
# Check that all custom field columns are displayed
|
||||
assert html =~ field1.name
|
||||
assert html =~ field2.name
|
||||
assert html =~ field3.name
|
||||
|
||||
# Check that all values are displayed
|
||||
assert html =~ "Value1"
|
||||
assert html =~ "Value2"
|
||||
assert html =~ "Value3"
|
||||
end
|
||||
end
|
||||
459
test/mv_web/member_live/index_custom_fields_sorting_test.exs
Normal file
459
test/mv_web/member_live/index_custom_fields_sorting_test.exs
Normal file
|
|
@ -0,0 +1,459 @@
|
|||
defmodule MvWeb.MemberLive.IndexCustomFieldsSortingTest do
|
||||
@moduledoc """
|
||||
Tests for sorting by custom fields in the member overview.
|
||||
|
||||
Tests cover:
|
||||
- Sorting by custom field (ascending)
|
||||
- Sorting by custom field (descending)
|
||||
- Sorting by custom field works with search
|
||||
- Sorting by custom field works with URL parameters
|
||||
- Sorting by custom field works with other columns
|
||||
"""
|
||||
use MvWeb.ConnCase, async: true
|
||||
import Phoenix.LiveViewTest
|
||||
require Ash.Query
|
||||
|
||||
alias Mv.Membership.{CustomField, CustomFieldValue, Member}
|
||||
|
||||
setup do
|
||||
# Create test members
|
||||
{:ok, member1} =
|
||||
Member
|
||||
|> Ash.Changeset.for_create(:create_member, %{
|
||||
first_name: "Alice",
|
||||
last_name: "Anderson",
|
||||
email: "alice@example.com"
|
||||
})
|
||||
|> Ash.create()
|
||||
|
||||
{:ok, member2} =
|
||||
Member
|
||||
|> Ash.Changeset.for_create(:create_member, %{
|
||||
first_name: "Bob",
|
||||
last_name: "Brown",
|
||||
email: "bob@example.com"
|
||||
})
|
||||
|> Ash.create()
|
||||
|
||||
{:ok, member3} =
|
||||
Member
|
||||
|> Ash.Changeset.for_create(:create_member, %{
|
||||
first_name: "Charlie",
|
||||
last_name: "Clark",
|
||||
email: "charlie@example.com"
|
||||
})
|
||||
|> Ash.create()
|
||||
|
||||
# Create custom field with show_in_overview: true
|
||||
{:ok, field_string} =
|
||||
CustomField
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
name: "membership_number",
|
||||
value_type: :string,
|
||||
show_in_overview: true
|
||||
})
|
||||
|> Ash.create()
|
||||
|
||||
{:ok, field_integer} =
|
||||
CustomField
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
name: "priority",
|
||||
value_type: :integer,
|
||||
show_in_overview: true
|
||||
})
|
||||
|> Ash.create()
|
||||
|
||||
# Create custom field values
|
||||
{:ok, _cfv1} =
|
||||
CustomFieldValue
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
member_id: member1.id,
|
||||
custom_field_id: field_string.id,
|
||||
value: %{"_union_type" => "string", "_union_value" => "A001"}
|
||||
})
|
||||
|> Ash.create()
|
||||
|
||||
{:ok, _cfv2} =
|
||||
CustomFieldValue
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
member_id: member2.id,
|
||||
custom_field_id: field_string.id,
|
||||
value: %{"_union_type" => "string", "_union_value" => "C003"}
|
||||
})
|
||||
|> Ash.create()
|
||||
|
||||
{:ok, _cfv3} =
|
||||
CustomFieldValue
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
member_id: member3.id,
|
||||
custom_field_id: field_string.id,
|
||||
value: %{"_union_type" => "string", "_union_value" => "B002"}
|
||||
})
|
||||
|> Ash.create()
|
||||
|
||||
{:ok, _cfv4} =
|
||||
CustomFieldValue
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
member_id: member1.id,
|
||||
custom_field_id: field_integer.id,
|
||||
value: %{"_union_type" => "integer", "_union_value" => 10}
|
||||
})
|
||||
|> Ash.create()
|
||||
|
||||
{:ok, _cfv5} =
|
||||
CustomFieldValue
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
member_id: member2.id,
|
||||
custom_field_id: field_integer.id,
|
||||
value: %{"_union_type" => "integer", "_union_value" => 30}
|
||||
})
|
||||
|> Ash.create()
|
||||
|
||||
{:ok, _cfv6} =
|
||||
CustomFieldValue
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
member_id: member3.id,
|
||||
custom_field_id: field_integer.id,
|
||||
value: %{"_union_type" => "integer", "_union_value" => 20}
|
||||
})
|
||||
|> Ash.create()
|
||||
|
||||
%{
|
||||
member1: member1,
|
||||
member2: member2,
|
||||
member3: member3,
|
||||
field_string: field_string,
|
||||
field_integer: field_integer
|
||||
}
|
||||
end
|
||||
|
||||
test "sorts by custom field ascending", %{conn: conn, field_string: field} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, view, _html} = live(conn, "/members")
|
||||
|
||||
# Click on custom field column header to sort
|
||||
view
|
||||
|> element("[data-testid='custom_field_#{field.id}']")
|
||||
|> render_click()
|
||||
|
||||
# Check URL was updated
|
||||
assert_patch(view, "/members?query=&sort_field=custom_field_#{field.id}&sort_order=asc")
|
||||
|
||||
# Verify sort state
|
||||
assert has_element?(view, "[data-testid='custom_field_#{field.id}'][aria-label='ascending']")
|
||||
end
|
||||
|
||||
test "sorts by custom field descending", %{conn: conn, field_string: field} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, view, _html} = live(conn, "/members?sort_field=custom_field_#{field.id}&sort_order=asc")
|
||||
|
||||
# Click again to toggle to descending
|
||||
view
|
||||
|> element("[data-testid='custom_field_#{field.id}']")
|
||||
|> render_click()
|
||||
|
||||
# Check URL was updated
|
||||
assert_patch(view, "/members?query=&sort_field=custom_field_#{field.id}&sort_order=desc")
|
||||
|
||||
# Verify sort state
|
||||
assert has_element?(view, "[data-testid='custom_field_#{field.id}'][aria-label='descending']")
|
||||
end
|
||||
|
||||
test "sorting by custom field works with search", %{conn: conn, field_string: field} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, view, _html} = live(conn, "/members?query=Alice")
|
||||
|
||||
# Click on custom field column header to sort
|
||||
view
|
||||
|> element("[data-testid='custom_field_#{field.id}']")
|
||||
|> render_click()
|
||||
|
||||
# Check URL maintains search query
|
||||
assert_patch(view, "/members?query=Alice&sort_field=custom_field_#{field.id}&sort_order=asc")
|
||||
end
|
||||
|
||||
test "sorting by custom field works with URL parameters", %{conn: conn, field_string: field} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
|
||||
{:ok, view, _html} =
|
||||
live(conn, "/members?query=&sort_field=custom_field_#{field.id}&sort_order=desc")
|
||||
|
||||
# Check that the sort state is correctly applied
|
||||
assert has_element?(view, "[data-testid='custom_field_#{field.id}'][aria-label='descending']")
|
||||
end
|
||||
|
||||
test "clicking different custom field column resets order to ascending", %{
|
||||
conn: conn,
|
||||
field_string: field_string,
|
||||
field_integer: field_integer
|
||||
} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
|
||||
{:ok, view, _html} =
|
||||
live(conn, "/members?query=&sort_field=custom_field_#{field_string.id}&sort_order=desc")
|
||||
|
||||
# Click on a different custom field column
|
||||
view
|
||||
|> element("[data-testid='custom_field_#{field_integer.id}']")
|
||||
|> render_click()
|
||||
|
||||
assert_patch(
|
||||
view,
|
||||
"/members?query=&sort_field=custom_field_#{field_integer.id}&sort_order=asc"
|
||||
)
|
||||
end
|
||||
|
||||
test "clicking regular column after custom field column works", %{
|
||||
conn: conn,
|
||||
field_string: field
|
||||
} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
|
||||
{:ok, view, _html} =
|
||||
live(conn, "/members?query=&sort_field=custom_field_#{field.id}&sort_order=desc")
|
||||
|
||||
# Click on email column
|
||||
view
|
||||
|> element("[data-testid='email']")
|
||||
|> render_click()
|
||||
|
||||
assert_patch(view, "/members?query=&sort_field=email&sort_order=asc")
|
||||
end
|
||||
|
||||
test "clicking custom field column after regular column works", %{
|
||||
conn: conn,
|
||||
field_string: field
|
||||
} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, view, _html} = live(conn, "/members?query=&sort_field=email&sort_order=desc")
|
||||
|
||||
# Click on custom field column
|
||||
view
|
||||
|> element("[data-testid='custom_field_#{field.id}']")
|
||||
|> render_click()
|
||||
|
||||
assert_patch(view, "/members?query=&sort_field=custom_field_#{field.id}&sort_order=asc")
|
||||
end
|
||||
|
||||
test "NULL values and empty strings are always sorted last (ASC)", %{conn: conn} do
|
||||
# Create additional members with NULL and empty string values
|
||||
{:ok, member_with_value} =
|
||||
Member
|
||||
|> Ash.Changeset.for_create(:create_member, %{
|
||||
first_name: "WithValue",
|
||||
last_name: "Test",
|
||||
email: "withvalue@example.com"
|
||||
})
|
||||
|> Ash.create()
|
||||
|
||||
{:ok, member_with_empty} =
|
||||
Member
|
||||
|> Ash.Changeset.for_create(:create_member, %{
|
||||
first_name: "WithEmpty",
|
||||
last_name: "Test",
|
||||
email: "withempty@example.com"
|
||||
})
|
||||
|> Ash.create()
|
||||
|
||||
{:ok, member_with_null} =
|
||||
Member
|
||||
|> Ash.Changeset.for_create(:create_member, %{
|
||||
first_name: "WithNull",
|
||||
last_name: "Test",
|
||||
email: "withnull@example.com"
|
||||
})
|
||||
|> Ash.create()
|
||||
|
||||
{:ok, member_with_another_value} =
|
||||
Member
|
||||
|> Ash.Changeset.for_create(:create_member, %{
|
||||
first_name: "AnotherValue",
|
||||
last_name: "Test",
|
||||
email: "another@example.com"
|
||||
})
|
||||
|> Ash.create()
|
||||
|
||||
# Create custom field
|
||||
{:ok, field} =
|
||||
CustomField
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
name: "test_field",
|
||||
value_type: :string,
|
||||
show_in_overview: true
|
||||
})
|
||||
|> Ash.create()
|
||||
|
||||
# Create values: one with actual value, one with empty string, one with NULL (no value), another with value
|
||||
{:ok, _cfv1} =
|
||||
CustomFieldValue
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
member_id: member_with_value.id,
|
||||
custom_field_id: field.id,
|
||||
value: %{"_union_type" => "string", "_union_value" => "Zebra"}
|
||||
})
|
||||
|> Ash.create()
|
||||
|
||||
{:ok, _cfv2} =
|
||||
CustomFieldValue
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
member_id: member_with_empty.id,
|
||||
custom_field_id: field.id,
|
||||
value: %{"_union_type" => "string", "_union_value" => ""}
|
||||
})
|
||||
|> Ash.create()
|
||||
|
||||
# member_with_null has no custom field value (NULL)
|
||||
|
||||
{:ok, _cfv3} =
|
||||
CustomFieldValue
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
member_id: member_with_another_value.id,
|
||||
custom_field_id: field.id,
|
||||
value: %{"_union_type" => "string", "_union_value" => "Apple"}
|
||||
})
|
||||
|> Ash.create()
|
||||
|
||||
conn = conn_with_oidc_user(conn)
|
||||
|
||||
{:ok, view, _html} =
|
||||
live(conn, "/members?query=&sort_field=custom_field_#{field.id}&sort_order=asc")
|
||||
|
||||
html = render(view)
|
||||
|
||||
# Find positions of member first names in the HTML to verify sort order
|
||||
apple_pos = :binary.match(html, member_with_another_value.first_name)
|
||||
zebra_pos = :binary.match(html, member_with_value.first_name)
|
||||
empty_pos = :binary.match(html, member_with_empty.first_name)
|
||||
null_pos = :binary.match(html, member_with_null.first_name)
|
||||
|
||||
assert apple_pos != :nomatch, "AnotherValue (Apple) should be in HTML"
|
||||
assert zebra_pos != :nomatch, "WithValue (Zebra) should be in HTML"
|
||||
assert empty_pos != :nomatch, "WithEmpty should be in HTML"
|
||||
assert null_pos != :nomatch, "WithNull should be in HTML"
|
||||
|
||||
{apple_idx, _} = apple_pos
|
||||
{zebra_idx, _} = zebra_pos
|
||||
{empty_idx, _} = empty_pos
|
||||
{null_idx, _} = null_pos
|
||||
|
||||
# In ASC order: Apple should come before Zebra
|
||||
assert apple_idx < zebra_idx, "Apple should come before Zebra in ASC order"
|
||||
|
||||
# NULL and empty should come after all values
|
||||
assert apple_idx < empty_idx, "Apple should come before empty value"
|
||||
assert apple_idx < null_idx, "Apple should come before NULL value"
|
||||
assert zebra_idx < empty_idx, "Zebra should come before empty value"
|
||||
assert zebra_idx < null_idx, "Zebra should come before NULL value"
|
||||
end
|
||||
|
||||
test "NULL values and empty strings are always sorted last (DESC)", %{conn: conn} do
|
||||
# Create additional members with NULL and empty string values
|
||||
{:ok, member_with_value} =
|
||||
Member
|
||||
|> Ash.Changeset.for_create(:create_member, %{
|
||||
first_name: "WithValue",
|
||||
last_name: "Test",
|
||||
email: "withvalue@example.com"
|
||||
})
|
||||
|> Ash.create()
|
||||
|
||||
{:ok, member_with_empty} =
|
||||
Member
|
||||
|> Ash.Changeset.for_create(:create_member, %{
|
||||
first_name: "WithEmpty",
|
||||
last_name: "Test",
|
||||
email: "withempty@example.com"
|
||||
})
|
||||
|> Ash.create()
|
||||
|
||||
{:ok, member_with_null} =
|
||||
Member
|
||||
|> Ash.Changeset.for_create(:create_member, %{
|
||||
first_name: "WithNull",
|
||||
last_name: "Test",
|
||||
email: "withnull@example.com"
|
||||
})
|
||||
|> Ash.create()
|
||||
|
||||
{:ok, member_with_another_value} =
|
||||
Member
|
||||
|> Ash.Changeset.for_create(:create_member, %{
|
||||
first_name: "AnotherValue",
|
||||
last_name: "Test",
|
||||
email: "another@example.com"
|
||||
})
|
||||
|> Ash.create()
|
||||
|
||||
# Create custom field
|
||||
{:ok, field} =
|
||||
CustomField
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
name: "test_field",
|
||||
value_type: :string,
|
||||
show_in_overview: true
|
||||
})
|
||||
|> Ash.create()
|
||||
|
||||
# Create values: one with actual value, one with empty string, one with NULL (no value), another with value
|
||||
{:ok, _cfv1} =
|
||||
CustomFieldValue
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
member_id: member_with_value.id,
|
||||
custom_field_id: field.id,
|
||||
value: %{"_union_type" => "string", "_union_value" => "Apple"}
|
||||
})
|
||||
|> Ash.create()
|
||||
|
||||
{:ok, _cfv2} =
|
||||
CustomFieldValue
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
member_id: member_with_empty.id,
|
||||
custom_field_id: field.id,
|
||||
value: %{"_union_type" => "string", "_union_value" => ""}
|
||||
})
|
||||
|> Ash.create()
|
||||
|
||||
# member_with_null has no custom field value (NULL)
|
||||
|
||||
{:ok, _cfv3} =
|
||||
CustomFieldValue
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
member_id: member_with_another_value.id,
|
||||
custom_field_id: field.id,
|
||||
value: %{"_union_type" => "string", "_union_value" => "Zebra"}
|
||||
})
|
||||
|> Ash.create()
|
||||
|
||||
conn = conn_with_oidc_user(conn)
|
||||
|
||||
{:ok, view, _html} =
|
||||
live(conn, "/members?query=&sort_field=custom_field_#{field.id}&sort_order=desc")
|
||||
|
||||
html = render(view)
|
||||
|
||||
# Find positions of member first names in the HTML to verify sort order
|
||||
apple_pos = :binary.match(html, member_with_value.first_name)
|
||||
zebra_pos = :binary.match(html, member_with_another_value.first_name)
|
||||
empty_pos = :binary.match(html, member_with_empty.first_name)
|
||||
null_pos = :binary.match(html, member_with_null.first_name)
|
||||
|
||||
assert apple_pos != :nomatch, "WithValue (Apple) should be in HTML"
|
||||
assert zebra_pos != :nomatch, "AnotherValue (Zebra) should be in HTML"
|
||||
assert empty_pos != :nomatch, "WithEmpty should be in HTML"
|
||||
assert null_pos != :nomatch, "WithNull should be in HTML"
|
||||
|
||||
{apple_idx, _} = apple_pos
|
||||
{zebra_idx, _} = zebra_pos
|
||||
{empty_idx, _} = empty_pos
|
||||
{null_idx, _} = null_pos
|
||||
|
||||
# In DESC order: Zebra should come before Apple
|
||||
assert zebra_idx < apple_idx, "Zebra should come before Apple in DESC order"
|
||||
|
||||
# NULL and empty should come after all values
|
||||
assert zebra_idx < empty_idx, "Zebra should come before empty value"
|
||||
assert zebra_idx < null_idx, "Zebra should come before NULL value"
|
||||
assert apple_idx < empty_idx, "Apple should come before empty value"
|
||||
assert apple_idx < null_idx, "Apple should come before NULL value"
|
||||
end
|
||||
end
|
||||
452
test/mv_web/member_live/index_field_visibility_test.exs
Normal file
452
test/mv_web/member_live/index_field_visibility_test.exs
Normal file
|
|
@ -0,0 +1,452 @@
|
|||
defmodule MvWeb.MemberLive.IndexFieldVisibilityTest do
|
||||
@moduledoc """
|
||||
Integration tests for field visibility dropdown functionality.
|
||||
|
||||
Tests cover:
|
||||
- Field selection dropdown rendering
|
||||
- Toggling field visibility
|
||||
- URL parameter persistence
|
||||
- Select all / deselect all
|
||||
- Integration with member list display
|
||||
- Custom fields visibility
|
||||
"""
|
||||
use MvWeb.ConnCase, async: true
|
||||
|
||||
import Phoenix.LiveViewTest
|
||||
require Ash.Query
|
||||
|
||||
alias Mv.Membership.{CustomField, CustomFieldValue, Member}
|
||||
|
||||
setup do
|
||||
# Create test members
|
||||
{:ok, member1} =
|
||||
Member
|
||||
|> Ash.Changeset.for_create(:create_member, %{
|
||||
first_name: "Alice",
|
||||
last_name: "Anderson",
|
||||
email: "alice@example.com",
|
||||
street: "Main St",
|
||||
city: "Berlin"
|
||||
})
|
||||
|> Ash.create()
|
||||
|
||||
{:ok, member2} =
|
||||
Member
|
||||
|> Ash.Changeset.for_create(:create_member, %{
|
||||
first_name: "Bob",
|
||||
last_name: "Brown",
|
||||
email: "bob@example.com",
|
||||
street: "Second St",
|
||||
city: "Hamburg"
|
||||
})
|
||||
|> Ash.create()
|
||||
|
||||
# Create custom field
|
||||
{:ok, custom_field} =
|
||||
CustomField
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
name: "membership_number",
|
||||
value_type: :string,
|
||||
show_in_overview: true
|
||||
})
|
||||
|> Ash.create()
|
||||
|
||||
# Create custom field values
|
||||
{:ok, _cfv1} =
|
||||
CustomFieldValue
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
member_id: member1.id,
|
||||
custom_field_id: custom_field.id,
|
||||
value: "M001"
|
||||
})
|
||||
|> Ash.create()
|
||||
|
||||
{:ok, _cfv2} =
|
||||
CustomFieldValue
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
member_id: member2.id,
|
||||
custom_field_id: custom_field.id,
|
||||
value: "M002"
|
||||
})
|
||||
|> Ash.create()
|
||||
|
||||
%{
|
||||
member1: member1,
|
||||
member2: member2,
|
||||
custom_field: custom_field
|
||||
}
|
||||
end
|
||||
|
||||
describe "field visibility dropdown" do
|
||||
test "renders dropdown button", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, _view, html} = live(conn, "/members")
|
||||
|
||||
assert html =~ "Columns"
|
||||
assert html =~ ~s(aria-controls="field-visibility-menu")
|
||||
end
|
||||
|
||||
test "opens dropdown when button is clicked", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, view, _html} = live(conn, "/members")
|
||||
|
||||
# Initially closed
|
||||
refute has_element?(view, "ul#field-visibility-menu")
|
||||
|
||||
# Click button
|
||||
view
|
||||
|> element("button[aria-controls='field-visibility-menu']")
|
||||
|> render_click()
|
||||
|
||||
# Should be open now
|
||||
assert has_element?(view, "ul#field-visibility-menu")
|
||||
end
|
||||
|
||||
test "displays all member fields in dropdown", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, view, _html} = live(conn, "/members")
|
||||
|
||||
# Open dropdown
|
||||
view
|
||||
|> element("button[aria-controls='field-visibility-menu']")
|
||||
|> render_click()
|
||||
|
||||
html = render(view)
|
||||
|
||||
# Check for member fields (formatted labels)
|
||||
assert html =~ "First Name" or html =~ "first_name"
|
||||
assert html =~ "Email" or html =~ "email"
|
||||
assert html =~ "Street" or html =~ "street"
|
||||
end
|
||||
|
||||
test "displays custom fields in dropdown", %{conn: conn, custom_field: custom_field} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, view, _html} = live(conn, "/members")
|
||||
|
||||
# Open dropdown
|
||||
view
|
||||
|> element("button[aria-controls='field-visibility-menu']")
|
||||
|> render_click()
|
||||
|
||||
html = render(view)
|
||||
|
||||
assert html =~ custom_field.name
|
||||
end
|
||||
end
|
||||
|
||||
describe "field visibility toggling" do
|
||||
test "hiding a field removes it from display", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, view, _html} = live(conn, "/members")
|
||||
|
||||
# Verify email is visible initially
|
||||
html = render(view)
|
||||
assert html =~ "alice@example.com"
|
||||
|
||||
# Open dropdown and hide email
|
||||
view
|
||||
|> element("button[aria-controls='field-visibility-menu']")
|
||||
|> render_click()
|
||||
|
||||
view
|
||||
|> element("button[phx-click='select_item'][phx-value-item='email']")
|
||||
|> render_click()
|
||||
|
||||
# Wait for update
|
||||
:timer.sleep(100)
|
||||
|
||||
# Email should no longer be visible
|
||||
html = render(view)
|
||||
refute html =~ "alice@example.com"
|
||||
refute html =~ "bob@example.com"
|
||||
end
|
||||
|
||||
test "hiding custom field removes it from display", %{conn: conn, custom_field: custom_field} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, view, _html} = live(conn, "/members")
|
||||
|
||||
# Verify custom field is visible initially
|
||||
html = render(view)
|
||||
assert html =~ "M001" or html =~ custom_field.name
|
||||
|
||||
# Open dropdown and hide custom field
|
||||
view
|
||||
|> element("button[aria-controls='field-visibility-menu']")
|
||||
|> render_click()
|
||||
|
||||
custom_field_id = custom_field.id
|
||||
custom_field_string = "custom_field_#{custom_field_id}"
|
||||
|
||||
view
|
||||
|> element("button[phx-click='select_item'][phx-value-item='#{custom_field_string}']")
|
||||
|> render_click()
|
||||
|
||||
# Wait for update
|
||||
:timer.sleep(100)
|
||||
|
||||
# Custom field should no longer be visible
|
||||
html = render(view)
|
||||
refute html =~ "M001"
|
||||
refute html =~ "M002"
|
||||
end
|
||||
end
|
||||
|
||||
describe "select all / deselect all" do
|
||||
test "select all makes all fields visible", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
|
||||
# Start with some fields hidden
|
||||
{:ok, view, _html} = live(conn, "/members?fields=first_name")
|
||||
|
||||
# Open dropdown
|
||||
view
|
||||
|> element("button[aria-controls='field-visibility-menu']")
|
||||
|> render_click()
|
||||
|
||||
# Click select all
|
||||
view
|
||||
|> element("button[phx-click='select_all']")
|
||||
|> render_click()
|
||||
|
||||
# Wait for update
|
||||
:timer.sleep(100)
|
||||
|
||||
# All fields should be visible
|
||||
html = render(view)
|
||||
assert html =~ "alice@example.com"
|
||||
assert html =~ "Main St"
|
||||
assert html =~ "Berlin"
|
||||
end
|
||||
|
||||
test "deselect all hides all fields except first_name", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, view, _html} = live(conn, "/members")
|
||||
|
||||
# Open dropdown
|
||||
view
|
||||
|> element("button[aria-controls='field-visibility-menu']")
|
||||
|> render_click()
|
||||
|
||||
# Click deselect all
|
||||
view
|
||||
|> element("button[phx-click='select_none']")
|
||||
|> render_click()
|
||||
|
||||
# Wait for update
|
||||
:timer.sleep(100)
|
||||
|
||||
# Only first_name should be visible (it's always shown)
|
||||
html = render(view)
|
||||
# Email and street should be hidden
|
||||
refute html =~ "alice@example.com"
|
||||
refute html =~ "Main St"
|
||||
end
|
||||
end
|
||||
|
||||
describe "URL parameter persistence" do
|
||||
test "field selection is persisted in URL", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, view, _html} = live(conn, "/members")
|
||||
|
||||
# Open dropdown and hide email
|
||||
view
|
||||
|> element("button[aria-controls='field-visibility-menu']")
|
||||
|> render_click()
|
||||
|
||||
view
|
||||
|> element("button[phx-click='select_item'][phx-value-item='email']")
|
||||
|> render_click()
|
||||
|
||||
# Wait for URL update
|
||||
:timer.sleep(100)
|
||||
|
||||
# Check that URL contains fields parameter
|
||||
# Note: In LiveView tests, we check the rendered HTML for the updated state
|
||||
# The actual URL update happens via push_patch
|
||||
end
|
||||
|
||||
test "loading page with fields parameter applies selection", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
|
||||
# Load with first_name and city explicitly set in URL
|
||||
# Note: Other fields may still be visible due to global settings
|
||||
{:ok, view, _html} = live(conn, "/members?fields=first_name,city")
|
||||
|
||||
html = render(view)
|
||||
|
||||
# first_name and city should be visible
|
||||
assert html =~ "Alice"
|
||||
assert html =~ "Berlin"
|
||||
|
||||
# Note: email and street may still be visible if global settings allow it
|
||||
# This test verifies that the URL parameters work, not that they hide other fields
|
||||
end
|
||||
|
||||
test "fields parameter works with custom fields", %{conn: conn, custom_field: custom_field} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
custom_field_id = custom_field.id
|
||||
|
||||
# Load with custom field visible
|
||||
{:ok, view, _html} =
|
||||
live(conn, "/members?fields=first_name,custom_field_#{custom_field_id}")
|
||||
|
||||
html = render(view)
|
||||
|
||||
# Custom field should be visible
|
||||
assert html =~ "M001" or html =~ custom_field.name
|
||||
end
|
||||
end
|
||||
|
||||
describe "integration with global settings" do
|
||||
test "respects global settings when no user selection", %{conn: conn} do
|
||||
# This test would require setting up global settings
|
||||
# For now, we verify that the system works with default settings
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, _view, html} = live(conn, "/members")
|
||||
|
||||
# All fields should be visible by default
|
||||
assert html =~ "alice@example.com"
|
||||
assert html =~ "Main St"
|
||||
end
|
||||
|
||||
test "user selection overrides global settings", %{conn: conn} do
|
||||
# This would require setting up global settings first
|
||||
# Then verifying that user selection takes precedence
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, view, _html} = live(conn, "/members")
|
||||
|
||||
# Hide a field via dropdown
|
||||
view
|
||||
|> element("button[aria-controls='field-visibility-menu']")
|
||||
|> render_click()
|
||||
|
||||
view
|
||||
|> element("button[phx-click='select_item'][phx-value-item='email']")
|
||||
|> render_click()
|
||||
|
||||
:timer.sleep(100)
|
||||
|
||||
html = render(view)
|
||||
refute html =~ "alice@example.com"
|
||||
end
|
||||
end
|
||||
|
||||
describe "edge cases" do
|
||||
test "handles empty fields parameter", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, _view, html} = live(conn, "/members?fields=")
|
||||
|
||||
# Should fall back to global settings
|
||||
assert html =~ "alice@example.com"
|
||||
end
|
||||
|
||||
test "handles invalid field names in URL", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, _view, html} = live(conn, "/members?fields=invalid_field,another_invalid")
|
||||
|
||||
# Should ignore invalid fields and use defaults
|
||||
assert html =~ "alice@example.com"
|
||||
end
|
||||
|
||||
test "handles custom field that doesn't exist", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, _view, html} = live(conn, "/members?fields=first_name,custom_field_nonexistent")
|
||||
|
||||
# Should work without errors
|
||||
assert html =~ "Alice"
|
||||
end
|
||||
|
||||
test "handles rapid toggling", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, view, _html} = live(conn, "/members")
|
||||
|
||||
# Open dropdown
|
||||
view
|
||||
|> element("button[aria-controls='field-visibility-menu']")
|
||||
|> render_click()
|
||||
|
||||
# Rapidly toggle a field multiple times
|
||||
for _ <- 1..5 do
|
||||
view
|
||||
|> element("button[phx-click='select_item'][phx-value-item='email']")
|
||||
|> render_click()
|
||||
|
||||
:timer.sleep(50)
|
||||
end
|
||||
|
||||
# Should still work correctly
|
||||
html = render(view)
|
||||
assert html =~ "Alice"
|
||||
end
|
||||
end
|
||||
|
||||
describe "accessibility" do
|
||||
test "dropdown has proper ARIA attributes", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, _view, html} = live(conn, "/members")
|
||||
|
||||
assert html =~ ~s(aria-controls="field-visibility-menu")
|
||||
assert html =~ ~s(aria-haspopup="menu")
|
||||
assert html =~ ~s(role="button")
|
||||
end
|
||||
|
||||
test "menu items have proper ARIA attributes when open", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, view, _html} = live(conn, "/members")
|
||||
|
||||
# Open dropdown
|
||||
view
|
||||
|> element("button[aria-controls='field-visibility-menu']")
|
||||
|> render_click()
|
||||
|
||||
html = render(view)
|
||||
|
||||
assert html =~ ~s(role="menu")
|
||||
assert html =~ ~s(role="menuitemcheckbox")
|
||||
assert html =~ ~s(aria-checked)
|
||||
end
|
||||
|
||||
test "keyboard navigation works", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, view, _html} = live(conn, "/members")
|
||||
|
||||
# Open dropdown
|
||||
view
|
||||
|> element("button[aria-controls='field-visibility-menu']")
|
||||
|> render_click()
|
||||
|
||||
# Check that elements are keyboard accessible
|
||||
html = render(view)
|
||||
assert html =~ ~s(tabindex="0")
|
||||
# Check that keyboard events are supported
|
||||
assert html =~ ~s(phx-keydown="select_item")
|
||||
assert html =~ ~s(phx-key="Enter")
|
||||
end
|
||||
|
||||
test "keyboard activation with Enter key works", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, view, _html} = live(conn, "/members")
|
||||
|
||||
# Verify email is visible initially
|
||||
html = render(view)
|
||||
assert html =~ "alice@example.com"
|
||||
|
||||
# Open dropdown
|
||||
view
|
||||
|> element("button[aria-controls='field-visibility-menu']")
|
||||
|> render_click()
|
||||
|
||||
# Simulate Enter key press on email field button
|
||||
view
|
||||
|> element("button[phx-click='select_item'][phx-value-item='email']")
|
||||
|> render_keydown(%{key: "Enter"})
|
||||
|
||||
# Wait for update
|
||||
:timer.sleep(100)
|
||||
|
||||
# Email should no longer be visible
|
||||
html = render(view)
|
||||
refute html =~ "alice@example.com"
|
||||
end
|
||||
end
|
||||
end
|
||||
64
test/mv_web/member_live/index_member_fields_display_test.exs
Normal file
64
test/mv_web/member_live/index_member_fields_display_test.exs
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
defmodule MvWeb.MemberLive.IndexMemberFieldsDisplayTest do
|
||||
use MvWeb.ConnCase, async: true
|
||||
import Phoenix.LiveViewTest
|
||||
require Ash.Query
|
||||
|
||||
alias Mv.Membership.Member
|
||||
|
||||
setup do
|
||||
{:ok, member1} =
|
||||
Member
|
||||
|> Ash.Changeset.for_create(:create_member, %{
|
||||
first_name: "Alice",
|
||||
last_name: "Anderson",
|
||||
email: "alice@example.com",
|
||||
street: "Main Street",
|
||||
house_number: "123",
|
||||
postal_code: "12345",
|
||||
city: "Berlin",
|
||||
phone_number: "+49123456789",
|
||||
join_date: ~D[2020-01-15]
|
||||
})
|
||||
|> Ash.create()
|
||||
|
||||
{:ok, member2} =
|
||||
Member
|
||||
|> Ash.Changeset.for_create(:create_member, %{
|
||||
first_name: "Bob",
|
||||
last_name: "Brown",
|
||||
email: "bob@example.com"
|
||||
})
|
||||
|> Ash.create()
|
||||
|
||||
%{
|
||||
member1: member1,
|
||||
member2: member2
|
||||
}
|
||||
end
|
||||
|
||||
test "shows multiple members correctly", %{conn: conn, member1: m1, member2: m2} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, _view, html} = live(conn, "/members")
|
||||
|
||||
for m <- [m1, m2], field <- [m.first_name, m.last_name, m.email] do
|
||||
assert html =~ field
|
||||
end
|
||||
end
|
||||
|
||||
test "respects show_in_overview config", %{conn: conn, member1: m} do
|
||||
{:ok, settings} = Mv.Membership.get_settings()
|
||||
fields_to_hide = [:street, :house_number]
|
||||
|
||||
{:ok, _} =
|
||||
Mv.Membership.update_settings(settings, %{
|
||||
member_field_visibility: Map.new(fields_to_hide, &{Atom.to_string(&1), false})
|
||||
})
|
||||
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, _view, html} = live(conn, "/members")
|
||||
|
||||
assert html =~ "Email"
|
||||
assert html =~ m.email
|
||||
refute html =~ m.street
|
||||
end
|
||||
end
|
||||
|
|
@ -249,4 +249,441 @@ defmodule MvWeb.MemberLive.IndexTest do
|
|||
# Verify the member was actually deleted from the database
|
||||
assert not (Mv.Membership.Member |> Ash.Query.filter(id == ^member.id) |> Ash.exists?())
|
||||
end
|
||||
|
||||
describe "copy_emails feature" do
|
||||
setup do
|
||||
# Create test members
|
||||
{:ok, member1} =
|
||||
Mv.Membership.create_member(%{
|
||||
first_name: "Max",
|
||||
last_name: "Mustermann",
|
||||
email: "max@example.com"
|
||||
})
|
||||
|
||||
{:ok, member2} =
|
||||
Mv.Membership.create_member(%{
|
||||
first_name: "Erika",
|
||||
last_name: "Musterfrau",
|
||||
email: "erika@example.com"
|
||||
})
|
||||
|
||||
{:ok, member3} =
|
||||
Mv.Membership.create_member(%{
|
||||
first_name: "Hans",
|
||||
last_name: "Müller-Lüdenscheidt",
|
||||
email: "hans@example.com"
|
||||
})
|
||||
|
||||
%{member1: member1, member2: member2, member3: member3}
|
||||
end
|
||||
|
||||
test "copy_emails event formats selected members correctly", %{
|
||||
conn: conn,
|
||||
member1: member1,
|
||||
member2: member2
|
||||
} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, view, _html} = live(conn, "/members")
|
||||
|
||||
# Select two members
|
||||
view
|
||||
|> element("[phx-click='select_member'][phx-value-id='#{member1.id}']")
|
||||
|> render_click()
|
||||
|
||||
view
|
||||
|> element("[phx-click='select_member'][phx-value-id='#{member2.id}']")
|
||||
|> render_click()
|
||||
|
||||
# Trigger copy_emails event
|
||||
view |> element("#copy-emails-btn") |> render_click()
|
||||
|
||||
# Verify flash message shows correct count
|
||||
assert render(view) =~ "2"
|
||||
end
|
||||
|
||||
test "copy_emails event with no selection shows error flash", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, view, _html} = live(conn, "/members")
|
||||
|
||||
# Trigger copy_emails event directly (button not visible when no selection)
|
||||
# This tests the edge case where event is triggered without selection
|
||||
result = render_hook(view, "copy_emails", %{})
|
||||
|
||||
# Should show error flash
|
||||
assert result =~ "No members selected" or result =~ "Keine Mitglieder"
|
||||
end
|
||||
|
||||
test "copy_emails event with all members selected formats all emails", %{
|
||||
conn: conn
|
||||
} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, view, _html} = live(conn, "/members")
|
||||
|
||||
# Select all members via select_all
|
||||
view |> element("[phx-click='select_all']") |> render_click()
|
||||
|
||||
# Trigger copy_emails event
|
||||
view |> element("#copy-emails-btn") |> render_click()
|
||||
|
||||
# Verify flash message shows correct count (3 members)
|
||||
assert render(view) =~ "3"
|
||||
end
|
||||
|
||||
test "copy_emails handles members with special characters in names", %{
|
||||
conn: conn,
|
||||
member3: member3
|
||||
} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, view, _html} = live(conn, "/members")
|
||||
|
||||
# Select member with umlauts
|
||||
view
|
||||
|> element("[phx-click='select_member'][phx-value-id='#{member3.id}']")
|
||||
|> render_click()
|
||||
|
||||
# Trigger copy_emails event - should not crash
|
||||
view |> element("#copy-emails-btn") |> render_click()
|
||||
|
||||
# Verify flash message shows success
|
||||
assert render(view) =~ "1"
|
||||
end
|
||||
|
||||
test "copy_emails handles case where selected member is deleted before copy", %{
|
||||
conn: conn,
|
||||
member1: member1
|
||||
} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, view, _html} = live(conn, "/members")
|
||||
|
||||
# Select a member
|
||||
view
|
||||
|> element("[phx-click='select_member'][phx-value-id='#{member1.id}']")
|
||||
|> render_click()
|
||||
|
||||
# Delete the member from the database
|
||||
Ash.destroy!(member1)
|
||||
|
||||
# Trigger copy_emails event directly - selection still contains the deleted ID
|
||||
# but the member is no longer in @members list after reload
|
||||
result = render_hook(view, "copy_emails", %{})
|
||||
|
||||
# Should show error since no visible members match selection
|
||||
assert result =~ "No email" or result =~ "Keine E-Mail" or result =~ "0"
|
||||
end
|
||||
|
||||
test "copy_emails formats emails as RFC 5322 compliant comma-separated list", %{
|
||||
conn: conn,
|
||||
member1: member1,
|
||||
member2: member2
|
||||
} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, view, _html} = live(conn, "/members")
|
||||
|
||||
# Select two members
|
||||
view
|
||||
|> element("[phx-click='select_member'][phx-value-id='#{member1.id}']")
|
||||
|> render_click()
|
||||
|
||||
view
|
||||
|> element("[phx-click='select_member'][phx-value-id='#{member2.id}']")
|
||||
|> render_click()
|
||||
|
||||
# Get the socket state to verify the formatted email string
|
||||
state = :sys.get_state(view.pid)
|
||||
selected_members = state.socket.assigns.selected_members
|
||||
|
||||
# Verify MapSet is used
|
||||
assert %MapSet{} = selected_members
|
||||
assert MapSet.size(selected_members) == 2
|
||||
end
|
||||
|
||||
test "email format is 'First Last <email>' with comma separator", %{
|
||||
conn: conn,
|
||||
member1: _member1
|
||||
} do
|
||||
# Test the format_member_email function indirectly
|
||||
# by checking the push_event payload structure
|
||||
conn = conn_with_oidc_user(conn)
|
||||
|
||||
# Create a member with known data
|
||||
{:ok, test_member} =
|
||||
Mv.Membership.create_member(%{
|
||||
first_name: "Test",
|
||||
last_name: "Format",
|
||||
email: "test.format@example.com"
|
||||
})
|
||||
|
||||
{:ok, view, _html} = live(conn, "/members")
|
||||
|
||||
# Select the test member
|
||||
view
|
||||
|> element("[phx-click='select_member'][phx-value-id='#{test_member.id}']")
|
||||
|> render_click()
|
||||
|
||||
# The format should be "Test Format <test.format@example.com>"
|
||||
# We verify this by checking the flash shows 1 email was copied
|
||||
view |> element("#copy-emails-btn") |> render_click()
|
||||
assert render(view) =~ "1"
|
||||
end
|
||||
|
||||
test "copy button is not visible when no members are selected", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, view, _html} = live(conn, "/members")
|
||||
|
||||
# Ensure no members are selected (default state)
|
||||
refute has_element?(view, "#copy-emails-btn")
|
||||
end
|
||||
|
||||
test "copy button is visible when members are selected", %{
|
||||
conn: conn,
|
||||
member1: member1
|
||||
} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, view, _html} = live(conn, "/members")
|
||||
|
||||
# Select a member
|
||||
view
|
||||
|> element("[phx-click='select_member'][phx-value-id='#{member1.id}']")
|
||||
|> render_click()
|
||||
|
||||
# Button should now be visible
|
||||
assert has_element?(view, "#copy-emails-btn")
|
||||
end
|
||||
|
||||
test "copy button click triggers event and shows flash", %{
|
||||
conn: conn,
|
||||
member1: member1
|
||||
} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, view, _html} = live(conn, "/members")
|
||||
|
||||
# Select a member
|
||||
view
|
||||
|> element("[phx-click='select_member'][phx-value-id='#{member1.id}']")
|
||||
|> render_click()
|
||||
|
||||
# Click copy button
|
||||
view |> element("#copy-emails-btn") |> render_click()
|
||||
|
||||
# Flash message should appear
|
||||
assert has_element?(view, "#flash-group")
|
||||
end
|
||||
end
|
||||
|
||||
describe "payment filter integration" do
|
||||
setup do
|
||||
# Create members with different payment status
|
||||
# Use unique names that won't appear elsewhere in the HTML
|
||||
{:ok, paid_member} =
|
||||
Mv.Membership.create_member(%{
|
||||
first_name: "Zahler",
|
||||
last_name: "Mitglied",
|
||||
email: "zahler@example.com",
|
||||
paid: true
|
||||
})
|
||||
|
||||
{:ok, unpaid_member} =
|
||||
Mv.Membership.create_member(%{
|
||||
first_name: "Nichtzahler",
|
||||
last_name: "Mitglied",
|
||||
email: "nichtzahler@example.com",
|
||||
paid: false
|
||||
})
|
||||
|
||||
{:ok, nil_paid_member} =
|
||||
Mv.Membership.create_member(%{
|
||||
first_name: "Unbestimmt",
|
||||
last_name: "Mitglied",
|
||||
email: "unbestimmt@example.com"
|
||||
# paid is nil by default
|
||||
})
|
||||
|
||||
%{paid_member: paid_member, unpaid_member: unpaid_member, nil_paid_member: nil_paid_member}
|
||||
end
|
||||
|
||||
test "filter shows all members when no filter is active", %{
|
||||
conn: conn,
|
||||
paid_member: paid_member,
|
||||
unpaid_member: unpaid_member,
|
||||
nil_paid_member: nil_paid_member
|
||||
} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, _view, html} = live(conn, "/members")
|
||||
|
||||
assert html =~ paid_member.first_name
|
||||
assert html =~ unpaid_member.first_name
|
||||
assert html =~ nil_paid_member.first_name
|
||||
end
|
||||
|
||||
test "filter shows only paid members when paid filter is active", %{
|
||||
conn: conn,
|
||||
paid_member: paid_member,
|
||||
unpaid_member: unpaid_member,
|
||||
nil_paid_member: nil_paid_member
|
||||
} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, _view, html} = live(conn, "/members?paid_filter=paid")
|
||||
|
||||
assert html =~ paid_member.first_name
|
||||
refute html =~ unpaid_member.first_name
|
||||
refute html =~ nil_paid_member.first_name
|
||||
end
|
||||
|
||||
test "filter shows only unpaid members (including nil) when not_paid filter is active", %{
|
||||
conn: conn,
|
||||
paid_member: paid_member,
|
||||
unpaid_member: unpaid_member,
|
||||
nil_paid_member: nil_paid_member
|
||||
} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, _view, html} = live(conn, "/members?paid_filter=not_paid")
|
||||
|
||||
refute html =~ paid_member.first_name
|
||||
assert html =~ unpaid_member.first_name
|
||||
assert html =~ nil_paid_member.first_name
|
||||
end
|
||||
|
||||
test "filter combines with search query (AND)", %{
|
||||
conn: conn,
|
||||
paid_member: paid_member
|
||||
} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, _view, html} = live(conn, "/members?query=Zahler&paid_filter=paid")
|
||||
|
||||
assert html =~ paid_member.first_name
|
||||
end
|
||||
|
||||
test "filter combines with sorting", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
|
||||
{:ok, view, _html} =
|
||||
live(conn, "/members?paid_filter=paid&sort_field=first_name&sort_order=asc")
|
||||
|
||||
# Click on email sort header
|
||||
view
|
||||
|> element("[data-testid='email']")
|
||||
|> render_click()
|
||||
|
||||
# Filter should be preserved in URL
|
||||
path = assert_patch(view)
|
||||
assert path =~ "paid_filter=paid"
|
||||
assert path =~ "sort_field=email"
|
||||
end
|
||||
|
||||
test "URL parameter paid_filter is set when selecting filter", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, view, _html} = live(conn, "/members")
|
||||
|
||||
# Open filter dropdown
|
||||
view
|
||||
|> element("#payment-filter button[aria-haspopup='true']")
|
||||
|> render_click()
|
||||
|
||||
# Select "Paid" option
|
||||
view
|
||||
|> element("#payment-filter button[phx-value-filter='paid']")
|
||||
|> render_click()
|
||||
|
||||
path = assert_patch(view)
|
||||
assert path =~ "paid_filter=paid"
|
||||
end
|
||||
|
||||
test "URL parameter is correctly read on page load", %{
|
||||
conn: conn,
|
||||
paid_member: paid_member
|
||||
} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, _view, html} = live(conn, "/members?paid_filter=paid")
|
||||
|
||||
# Only paid member should be visible
|
||||
assert html =~ paid_member.first_name
|
||||
# Filter badge should be visible
|
||||
assert html =~ "badge"
|
||||
end
|
||||
|
||||
test "invalid URL parameter is ignored", %{
|
||||
conn: conn,
|
||||
paid_member: paid_member,
|
||||
unpaid_member: unpaid_member
|
||||
} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, _view, html} = live(conn, "/members?paid_filter=invalid_value")
|
||||
|
||||
# All members should be visible (filter not applied)
|
||||
assert html =~ paid_member.first_name
|
||||
assert html =~ unpaid_member.first_name
|
||||
end
|
||||
|
||||
test "search maintains filter state", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, view, _html} = live(conn, "/members?paid_filter=paid")
|
||||
|
||||
# Perform search
|
||||
view
|
||||
|> element("[data-testid='search-input']")
|
||||
|> render_change(%{"query" => "test"})
|
||||
|
||||
# Filter state should be maintained in URL
|
||||
path = assert_patch(view)
|
||||
assert path =~ "paid_filter=paid"
|
||||
end
|
||||
end
|
||||
|
||||
describe "paid column in table" do
|
||||
setup do
|
||||
{:ok, paid_member} =
|
||||
Mv.Membership.create_member(%{
|
||||
first_name: "Paid",
|
||||
last_name: "Member",
|
||||
email: "paid.column@example.com",
|
||||
paid: true
|
||||
})
|
||||
|
||||
{:ok, unpaid_member} =
|
||||
Mv.Membership.create_member(%{
|
||||
first_name: "Unpaid",
|
||||
last_name: "Member",
|
||||
email: "unpaid.column@example.com",
|
||||
paid: false
|
||||
})
|
||||
|
||||
%{paid_member: paid_member, unpaid_member: unpaid_member}
|
||||
end
|
||||
|
||||
test "paid column shows green badge for paid members", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, _view, html} = live(conn, "/members")
|
||||
|
||||
# Check for success badge (green)
|
||||
assert html =~ "badge-success"
|
||||
end
|
||||
|
||||
test "paid column shows red badge for unpaid members", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, _view, html} = live(conn, "/members")
|
||||
|
||||
# Check for error badge (red)
|
||||
assert html =~ "badge-error"
|
||||
end
|
||||
|
||||
test "paid column shows 'Yes' for paid members", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
Gettext.put_locale(MvWeb.Gettext, "en")
|
||||
{:ok, _view, html} = live(conn, "/members")
|
||||
|
||||
# The table should contain "Yes" text inside badge
|
||||
assert html =~ "badge-success"
|
||||
assert html =~ "Yes"
|
||||
end
|
||||
|
||||
test "paid column shows 'No' for unpaid members", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
Gettext.put_locale(MvWeb.Gettext, "en")
|
||||
{:ok, _view, html} = live(conn, "/members")
|
||||
|
||||
# The table should contain "No" text inside badge
|
||||
assert html =~ "badge-error"
|
||||
assert html =~ "No"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -33,8 +33,6 @@ defmodule MvWeb.UserLive.IndexTest do
|
|||
|
||||
assert html =~ "alice@example.com"
|
||||
assert html =~ "bob@example.com"
|
||||
assert html =~ "alice123"
|
||||
assert html =~ "bob456"
|
||||
end
|
||||
|
||||
test "shows correct action links", %{conn: conn} do
|
||||
|
|
@ -386,10 +384,6 @@ defmodule MvWeb.UserLive.IndexTest do
|
|||
|
||||
# Should still show the table structure
|
||||
assert html =~ "Email"
|
||||
assert html =~ "OIDC ID"
|
||||
# Should show the authenticated user at minimum
|
||||
# Matches the generated email pattern oidc.user{unique_id}@example.com
|
||||
assert html =~ "oidc.user"
|
||||
end
|
||||
|
||||
test "handles users with missing OIDC ID", %{conn: conn} do
|
||||
|
|
|
|||
|
|
@ -123,7 +123,13 @@ defmodule MvWeb.ConnCase do
|
|||
end
|
||||
|
||||
setup tags do
|
||||
Mv.DataCase.setup_sandbox(tags)
|
||||
{:ok, conn: Phoenix.ConnTest.build_conn()}
|
||||
pid = Mv.DataCase.setup_sandbox(tags)
|
||||
|
||||
conn = Phoenix.ConnTest.build_conn()
|
||||
# Set metadata for Phoenix.Ecto.SQL.Sandbox plug to allow LiveView processes
|
||||
# to share the test's database connection in async tests
|
||||
conn = Plug.Conn.put_private(conn, :ecto_sandbox, pid)
|
||||
|
||||
{:ok, conn: conn}
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -34,10 +34,12 @@ defmodule Mv.DataCase do
|
|||
|
||||
@doc """
|
||||
Sets up the sandbox based on the test tags.
|
||||
Returns the owner pid for use with Phoenix.Ecto.SQL.Sandbox.
|
||||
"""
|
||||
def setup_sandbox(tags) do
|
||||
pid = Ecto.Adapters.SQL.Sandbox.start_owner!(Mv.Repo, shared: not tags[:async])
|
||||
on_exit(fn -> Ecto.Adapters.SQL.Sandbox.stop_owner(pid) end)
|
||||
pid
|
||||
end
|
||||
|
||||
@doc """
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue