Compare commits
109 commits
a51695e74d
...
15bc2223f0
| Author | SHA1 | Date | |
|---|---|---|---|
| 15bc2223f0 | |||
|
|
110e7f6cbd | ||
| 3710d70024 | |||
| 3fd8483231 | |||
| f5ef16ec20 | |||
| 85a66f800e | |||
|
|
dbcfe6a29f | ||
| 0a2632102c | |||
| 9dba4d1019 | |||
|
|
1c60bc77b4 | ||
| d5ac168add | |||
| 00fe471bc0 | |||
| ca5fad0dcc | |||
| 1ec6188884 | |||
| 062dad99fb | |||
| 12f95c1998 | |||
| add855c8cb | |||
| 265e976d94 | |||
| 014ef04853 | |||
| 8c361cfc88 | |||
| c2302c5861 | |||
| a729d81bb9 | |||
| 37495095c9 | |||
|
|
9150188922 | ||
| 9ff7d7d17b | |||
| b1f6d29ca1 | |||
| a8cf6e1b18 | |||
| 720f640229 | |||
| 1675d66b67 | |||
| acd6d79efe | |||
| 280f024602 | |||
| 18641bb6ea | |||
| c3e95ca711 | |||
| 1b06f885bf | |||
| 8512be0282 | |||
| 89b02aeacf | |||
| d671103ba5 | |||
| 94de429529 | |||
| 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 | |||
| 6b0ec28d9b | |||
| 4057b2d631 | |||
| cd1af5aff5 | |||
| 8391122426 | |||
| a5aeef3e27 | |||
| 422cf37a1e | |||
| a10d42f1ed | |||
| d1bab1288c | |||
| 1623b63207 | |||
| 8e4f1ba674 | |||
| e6c5a58c65 | |||
| ee414c9440 | |||
| 366d4c104a | |||
|
|
26a46d966a | ||
| ce15b8f59b | |||
| f0613fe1e5 | |||
| d8384098b4 | |||
| ee094eec2f | |||
| 206e733511 | |||
| eedd24b93c | |||
| 06ba50f05d | |||
| 0fb43a0816 | |||
| 45a9bc0cc0 | |||
| d039e4bb7d | |||
| 7f0da693ee | |||
| 82e41916d2 | |||
| a022d8cd02 | |||
| f24d4985fc | |||
| cf957563bb |
75 changed files with 10233 additions and 1806 deletions
|
|
@ -4,7 +4,7 @@ name: check
|
||||||
|
|
||||||
services:
|
services:
|
||||||
- name: postgres
|
- name: postgres
|
||||||
image: docker.io/library/postgres:17.6
|
image: docker.io/library/postgres:17.7
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_USER: postgres
|
POSTGRES_USER: postgres
|
||||||
POSTGRES_PASSWORD: postgres
|
POSTGRES_PASSWORD: postgres
|
||||||
|
|
@ -57,7 +57,7 @@ steps:
|
||||||
- mix gettext.extract --check-up-to-date
|
- mix gettext.extract --check-up-to-date
|
||||||
|
|
||||||
- name: wait_for_postgres
|
- name: wait_for_postgres
|
||||||
image: docker.io/library/postgres:17.6
|
image: docker.io/library/postgres:17.7
|
||||||
commands:
|
commands:
|
||||||
# Wait for postgres to become available
|
# Wait for postgres to become available
|
||||||
- |
|
- |
|
||||||
|
|
@ -166,7 +166,7 @@ environment:
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: renovate
|
- name: renovate
|
||||||
image: renovate/renovate:41.151
|
image: renovate/renovate:42.44
|
||||||
environment:
|
environment:
|
||||||
RENOVATE_CONFIG_FILE: "renovate_backend_config.js"
|
RENOVATE_CONFIG_FILE: "renovate_backend_config.js"
|
||||||
RENOVATE_TOKEN:
|
RENOVATE_TOKEN:
|
||||||
|
|
|
||||||
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -41,3 +41,6 @@ npm-debug.log
|
||||||
.env
|
.env
|
||||||
|
|
||||||
.elixir_ls/
|
.elixir_ls/
|
||||||
|
|
||||||
|
# Docker secrets directory (generated by `just init-secrets`)
|
||||||
|
/secrets/
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,3 @@
|
||||||
elixir 1.18.3-otp-27
|
elixir 1.18.3-otp-27
|
||||||
erlang 27.3.4
|
erlang 27.3.4
|
||||||
just 1.43.0
|
just 1.43.1
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||||
- CopyToClipboard JavaScript hook with fallback for older browsers
|
- CopyToClipboard JavaScript hook with fallback for older browsers
|
||||||
- Button shows count of visible selected members (respects search/filter)
|
- Button shows count of visible selected members (respects search/filter)
|
||||||
- German/English translations
|
- 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
|
### Fixed
|
||||||
- Email validation false positive when linking user and member with identical emails (#168 Problem #4)
|
- Email validation false positive when linking user and member with identical emails (#168 Problem #4)
|
||||||
|
|
|
||||||
|
|
@ -90,4 +90,4 @@ USER nobody
|
||||||
# above and adding an entrypoint. See https://github.com/krallin/tini for details
|
# above and adding an entrypoint. See https://github.com/krallin/tini for details
|
||||||
# ENTRYPOINT ["/tini", "--"]
|
# ENTRYPOINT ["/tini", "--"]
|
||||||
|
|
||||||
CMD ["/app/bin/server"]
|
ENTRYPOINT ["/app/bin/docker-entrypoint.sh"]
|
||||||
|
|
|
||||||
28
Justfile
28
Justfile
|
|
@ -1,4 +1,7 @@
|
||||||
set dotenv-load := true
|
set dotenv-load := true
|
||||||
|
set export := true
|
||||||
|
|
||||||
|
MIX_QUIET := "1"
|
||||||
|
|
||||||
run: install-dependencies start-database migrate-database seed-database
|
run: install-dependencies start-database migrate-database seed-database
|
||||||
mix phx.server
|
mix phx.server
|
||||||
|
|
@ -90,4 +93,27 @@ clean:
|
||||||
remove-gettext-conflicts:
|
remove-gettext-conflicts:
|
||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
find priv/gettext -type f -exec sed -i '/^<<<<<<< HEAD$/d; /^=======$/d; /^>>>>>>>/d' {} \;
|
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
|
- 🚧 Sorting & filtering
|
||||||
- 🚧 Roles & permissions (e.g. board, treasurer)
|
- 🚧 Roles & permissions (e.g. board, treasurer)
|
||||||
- ✅ Custom fields (flexible per club needs)
|
- ✅ 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
|
- 🚧 Self-service & online application
|
||||||
- 🚧 Accessibility, GDPR, usability improvements
|
- 🚧 Accessibility, GDPR, usability improvements
|
||||||
- 🚧 Email sending
|
- 🚧 Email sending
|
||||||
|
|
@ -149,6 +149,25 @@ Mila uses OIDC for Single Sign-On. In development, a local **Rauthy** instance i
|
||||||
|
|
||||||
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
|
## ⚙️ Configuration
|
||||||
|
|
||||||
- **Env vars:** see `.env.example`
|
- **Env vars:** see `.env.example`
|
||||||
|
|
@ -210,13 +229,20 @@ For testing the production Docker build locally:
|
||||||
# Required variables:
|
# Required variables:
|
||||||
SECRET_KEY_BASE=<your-generated-secret>
|
SECRET_KEY_BASE=<your-generated-secret>
|
||||||
TOKEN_SIGNING_SECRET=<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_CLIENT_ID=mv
|
||||||
# OIDC_BASE_URL=http://localhost:8080/auth/v1
|
# OIDC_BASE_URL=http://localhost:8080/auth/v1
|
||||||
# OIDC_REDIRECT_URI=http://localhost:4001/auth/user/rauthy/callback
|
# OIDC_CLIENT_SECRET=<from-your-oidc-provider>
|
||||||
# OIDC_CLIENT_SECRET=<from-rauthy-client>
|
# 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):
|
3. **Start development environment** (for Rauthy):
|
||||||
|
|
@ -229,7 +255,7 @@ For testing the production Docker build locally:
|
||||||
docker compose -f docker-compose.prod.yml up
|
docker compose -f docker-compose.prod.yml up
|
||||||
```
|
```
|
||||||
|
|
||||||
5. **Run database migrations:**
|
5. **Database migrations run automatically** on app start. For manual migration:
|
||||||
```bash
|
```bash
|
||||||
docker compose -f docker-compose.prod.yml exec app /app/bin/mv eval "Mv.Release.migrate"
|
docker compose -f docker-compose.prod.yml exec app /app/bin/mv eval "Mv.Release.migrate"
|
||||||
```
|
```
|
||||||
|
|
@ -250,7 +276,7 @@ For actual production deployment:
|
||||||
- Set `OIDC_BASE_URL` to your production OIDC provider
|
- Set `OIDC_BASE_URL` to your production OIDC provider
|
||||||
- Configure proper Docker networks
|
- Configure proper Docker networks
|
||||||
3. **Set up SSL/TLS** (e.g., via reverse proxy like Nginx/Traefik)
|
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**
|
5. **Configure database backups**
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,75 @@ import Config
|
||||||
# any compile-time configuration in here, as it won't be applied.
|
# any compile-time configuration in here, as it won't be applied.
|
||||||
# The block below contains prod specific runtime configuration.
|
# 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
|
# ## Using releases
|
||||||
#
|
#
|
||||||
# If you use `mix release`, you need to explicitly enable the server
|
# If you use `mix release`, you need to explicitly enable the server
|
||||||
|
|
@ -21,12 +90,7 @@ if System.get_env("PHX_SERVER") do
|
||||||
end
|
end
|
||||||
|
|
||||||
if config_env() == :prod do
|
if config_env() == :prod do
|
||||||
database_url =
|
database_url = build_database_url.()
|
||||||
System.get_env("DATABASE_URL") ||
|
|
||||||
raise """
|
|
||||||
environment variable DATABASE_URL is missing.
|
|
||||||
For example: ecto://USER:PASS@HOST/DATABASE
|
|
||||||
"""
|
|
||||||
|
|
||||||
maybe_ipv6 = if System.get_env("ECTO_IPV6") in ~w(true 1), do: [:inet6], else: []
|
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
|
# 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
|
# to check this value into version control, so we use an environment
|
||||||
# variable instead.
|
# variable instead.
|
||||||
|
# Supports SECRET_KEY_BASE or SECRET_KEY_BASE_FILE for Docker secrets.
|
||||||
secret_key_base =
|
secret_key_base =
|
||||||
System.get_env("SECRET_KEY_BASE") ||
|
get_env_or_file!.("SECRET_KEY_BASE", """
|
||||||
raise """
|
environment variable SECRET_KEY_BASE (or SECRET_KEY_BASE_FILE) is missing.
|
||||||
environment variable SECRET_KEY_BASE is missing.
|
You can generate one by calling: mix phx.gen.secret
|
||||||
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")
|
port = String.to_integer(System.get_env("PORT") || "4000")
|
||||||
|
|
||||||
config :mv, :dns_cluster_query, System.get_env("DNS_CLUSTER_QUERY")
|
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,
|
config :mv, :rauthy,
|
||||||
client_id: System.get_env("OIDC_CLIENT_ID") || "mv",
|
client_id: oidc_client_id || "mv",
|
||||||
base_url: System.get_env("OIDC_BASE_URL") || "http://localhost:8080/auth/v1",
|
base_url: oidc_base_url || "http://localhost:8080/auth/v1",
|
||||||
client_secret: System.get_env("OIDC_CLIENT_SECRET"),
|
client_secret: client_secret,
|
||||||
redirect_uri:
|
redirect_uri: System.get_env("OIDC_REDIRECT_URI") || default_redirect_uri
|
||||||
System.get_env("OIDC_REDIRECT_URI") || "http://#{host}:#{port}/auth/user/rauthy/callback"
|
|
||||||
|
|
||||||
# Token signing secret from environment variable
|
# Token signing secret from environment variable
|
||||||
# This overrides the placeholder value set in prod.exs
|
# This overrides the placeholder value set in prod.exs
|
||||||
|
# Supports TOKEN_SIGNING_SECRET or TOKEN_SIGNING_SECRET_FILE for Docker secrets.
|
||||||
token_signing_secret =
|
token_signing_secret =
|
||||||
System.get_env("TOKEN_SIGNING_SECRET") ||
|
get_env_or_file!.("TOKEN_SIGNING_SECRET", """
|
||||||
raise """
|
environment variable TOKEN_SIGNING_SECRET (or TOKEN_SIGNING_SECRET_FILE) is missing.
|
||||||
environment variable TOKEN_SIGNING_SECRET is missing.
|
You can generate one by calling: mix phx.gen.secret
|
||||||
You can generate one by calling: mix phx.gen.secret
|
""")
|
||||||
"""
|
|
||||||
|
|
||||||
config :mv, :token_signing_secret, token_signing_secret
|
config :mv, :token_signing_secret, token_signing_secret
|
||||||
|
|
||||||
config :mv, MvWeb.Endpoint,
|
config :mv, MvWeb.Endpoint,
|
||||||
url: [host: host, port: 443, scheme: "https"],
|
url: [host: host, port: 443, scheme: "https"],
|
||||||
http: [
|
http: [
|
||||||
# Enable IPv6 and bind on all interfaces.
|
# Bind on all IPv4 interfaces.
|
||||||
# Set it to {0, 0, 0, 0, 0, 0, 0, 1} for local network only access.
|
# 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
|
# 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},
|
||||||
ip: {0, 0, 0, 0, 0, 0, 0, 0},
|
|
||||||
port: port
|
port: port
|
||||||
],
|
],
|
||||||
secret_key_base: secret_key_base,
|
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, :session_identifier, :unsafe
|
||||||
|
|
||||||
config :mv, :require_token_presence_for_authentication, false
|
config :mv, :require_token_presence_for_authentication, false
|
||||||
|
|
||||||
|
# Enable SQL Sandbox for async LiveView tests
|
||||||
|
config :mv, :sql_sandbox, true
|
||||||
|
|
|
||||||
|
|
@ -2,37 +2,60 @@ services:
|
||||||
app:
|
app:
|
||||||
image: git.local-it.org/local-it/mitgliederverwaltung:latest
|
image: git.local-it.org/local-it/mitgliederverwaltung:latest
|
||||||
container_name: mv-prod-app
|
container_name: mv-prod-app
|
||||||
# Use host network for local testing to access localhost:8080 (Rauthy)
|
ports:
|
||||||
# In real production, remove this and use external OIDC provider
|
- "4001:4001"
|
||||||
network_mode: host
|
|
||||||
environment:
|
environment:
|
||||||
DATABASE_URL: "ecto://postgres:postgres@localhost:5001/mv_prod"
|
# Database configuration using separate variables
|
||||||
SECRET_KEY_BASE: "${SECRET_KEY_BASE}"
|
# Use Docker service name for internal networking
|
||||||
TOKEN_SIGNING_SECRET: "${TOKEN_SIGNING_SECRET}"
|
DATABASE_HOST: "db-prod"
|
||||||
PHX_HOST: "${PHX_HOST}"
|
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"
|
PORT: "4001"
|
||||||
PHX_SERVER: "true"
|
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_CLIENT_ID: "mv"
|
||||||
OIDC_BASE_URL: "http://localhost:8080/auth/v1"
|
OIDC_BASE_URL: "http://host.docker.internal:8080/auth/v1"
|
||||||
OIDC_CLIENT_SECRET: "${OIDC_CLIENT_SECRET:-}"
|
OIDC_CLIENT_SECRET_FILE: "/run/secrets/oidc_client_secret"
|
||||||
OIDC_REDIRECT_URI: "http://localhost:4001/auth/user/rauthy/callback"
|
OIDC_REDIRECT_URI: "http://localhost:4001/auth/user/rauthy/callback"
|
||||||
|
secrets:
|
||||||
|
- db_password
|
||||||
|
- secret_key_base
|
||||||
|
- token_signing_secret
|
||||||
|
- oidc_client_secret
|
||||||
depends_on:
|
depends_on:
|
||||||
- db-prod
|
- db-prod
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
db-prod:
|
db-prod:
|
||||||
image: postgres:16-alpine
|
image: postgres:17.7-alpine
|
||||||
container_name: mv-prod-db
|
container_name: mv-prod-db
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_USER: postgres
|
POSTGRES_USER: postgres
|
||||||
POSTGRES_PASSWORD: postgres
|
POSTGRES_PASSWORD_FILE: /run/secrets/db_password
|
||||||
POSTGRES_DB: mv_prod
|
POSTGRES_DB: mv_prod
|
||||||
|
secrets:
|
||||||
|
- db_password
|
||||||
volumes:
|
volumes:
|
||||||
- postgres_data_prod:/var/lib/postgresql/data
|
- postgres_data_prod:/var/lib/postgresql/data
|
||||||
ports:
|
ports:
|
||||||
- "5001:5432"
|
- "5001:5432"
|
||||||
restart: unless-stopped
|
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:
|
volumes:
|
||||||
postgres_data_prod:
|
postgres_data_prod:
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ networks:
|
||||||
|
|
||||||
services:
|
services:
|
||||||
db:
|
db:
|
||||||
image: postgres:17.6-alpine
|
image: postgres:17.7-alpine
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_USER: postgres
|
POSTGRES_USER: postgres
|
||||||
POSTGRES_PASSWORD: postgres
|
POSTGRES_PASSWORD: postgres
|
||||||
|
|
|
||||||
243
docs/custom-fields-search-performance.md
Normal file
243
docs/custom-fields-search-performance.md
Normal file
|
|
@ -0,0 +1,243 @@
|
||||||
|
# Performance Analysis: Custom Fields in Search Vector
|
||||||
|
|
||||||
|
## Current Implementation
|
||||||
|
|
||||||
|
The search vector includes custom field values via database triggers that:
|
||||||
|
1. Aggregate all custom field values for a member
|
||||||
|
2. Extract values from JSONB format
|
||||||
|
3. Add them to the search_vector with weight 'C'
|
||||||
|
|
||||||
|
## Performance Considerations
|
||||||
|
|
||||||
|
### 1. Trigger Performance on Member Updates
|
||||||
|
|
||||||
|
**Current Implementation:**
|
||||||
|
- `members_search_vector_trigger()` executes a subquery on every INSERT/UPDATE:
|
||||||
|
```sql
|
||||||
|
SELECT string_agg(...) FROM custom_field_values WHERE member_id = NEW.id
|
||||||
|
```
|
||||||
|
|
||||||
|
**Performance Impact:**
|
||||||
|
- ✅ **Good:** Index on `member_id` exists (`custom_field_values_member_id_idx`)
|
||||||
|
- ✅ **Good:** Subquery only runs for the affected member
|
||||||
|
- ⚠️ **Potential Issue:** With many custom fields per member (e.g., 50+), aggregation could be slower
|
||||||
|
- ⚠️ **Potential Issue:** JSONB extraction (`value->>'_union_value'`) is relatively fast but adds overhead
|
||||||
|
|
||||||
|
**Expected Performance:**
|
||||||
|
- **Small scale (< 10 custom fields per member):** Negligible impact (< 5ms per operation)
|
||||||
|
- **Medium scale (10-30 custom fields):** Minor impact (5-20ms per operation)
|
||||||
|
- **Large scale (30+ custom fields):** Noticeable impact (20-50ms+ per operation)
|
||||||
|
|
||||||
|
### 2. Trigger Performance on Custom Field Value Changes
|
||||||
|
|
||||||
|
**Current Implementation:**
|
||||||
|
- `update_member_search_vector_from_custom_field_value()` executes on every INSERT/UPDATE/DELETE on `custom_field_values`
|
||||||
|
- **Optimized:** Only fetches required member fields (not full record) to reduce overhead
|
||||||
|
- **Optimized:** Skips re-aggregation on UPDATE if value hasn't actually changed
|
||||||
|
- Aggregates all custom field values, then updates member search_vector
|
||||||
|
|
||||||
|
**Performance Impact:**
|
||||||
|
- ✅ **Good:** Index on `member_id` ensures fast lookup
|
||||||
|
- ✅ **Optimized:** Only required fields are fetched (first_name, last_name, email, etc.) instead of full record
|
||||||
|
- ✅ **Optimized:** UPDATE operations that don't change the value skip expensive re-aggregation (early return)
|
||||||
|
- ⚠️ **Note:** Re-aggregation is still necessary when values change (required for search_vector consistency)
|
||||||
|
- ⚠️ **Critical:** Bulk operations (e.g., importing 1000 members with custom fields) will trigger this for each row
|
||||||
|
|
||||||
|
**Expected Performance:**
|
||||||
|
- **Single operation (value changed):** 3-10ms per custom field value change (improved from 5-15ms)
|
||||||
|
- **Single operation (value unchanged):** <1ms (early return, no aggregation)
|
||||||
|
- **Bulk operations:** Could be slow (consider disabling trigger temporarily)
|
||||||
|
|
||||||
|
### 3. Search Vector Size
|
||||||
|
|
||||||
|
**Current Constraints:**
|
||||||
|
- String values: max 10,000 characters per custom field
|
||||||
|
- No limit on number of custom fields per member
|
||||||
|
- tsvector has no explicit size limit, but very large vectors can cause issues
|
||||||
|
|
||||||
|
**Potential Issues:**
|
||||||
|
- **Theoretical maximum:** If a member has 100 custom fields with 10,000 char strings each, the aggregated text could be ~1MB
|
||||||
|
- **Practical concern:** Very large search vectors (> 100KB) can slow down:
|
||||||
|
- Index updates (GIN index maintenance)
|
||||||
|
- Search queries (tsvector operations)
|
||||||
|
- Trigger execution time
|
||||||
|
|
||||||
|
**Recommendation:**
|
||||||
|
- Monitor search_vector size in production
|
||||||
|
- Consider limiting total custom field content per member if needed
|
||||||
|
- PostgreSQL can handle large tsvectors, but performance degrades gradually
|
||||||
|
|
||||||
|
### 4. Initial Migration Performance
|
||||||
|
|
||||||
|
**Current Implementation:**
|
||||||
|
- Updates ALL members in a single transaction:
|
||||||
|
```sql
|
||||||
|
UPDATE members m SET search_vector = ... (subquery for each member)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Performance Impact:**
|
||||||
|
- ⚠️ **Potential Issue:** With 10,000+ members, this could take minutes
|
||||||
|
- ⚠️ **Potential Issue:** Single transaction locks the members table
|
||||||
|
- ⚠️ **Potential Issue:** If migration fails, entire rollback required
|
||||||
|
|
||||||
|
**Recommendation:**
|
||||||
|
- For large datasets (> 10,000 members), consider:
|
||||||
|
- Batch updates (e.g., 1000 members at a time)
|
||||||
|
- Run during maintenance window
|
||||||
|
- Monitor progress
|
||||||
|
|
||||||
|
### 5. Search Query Performance
|
||||||
|
|
||||||
|
**Current Implementation:**
|
||||||
|
- Full-text search uses GIN index on `search_vector` (fast)
|
||||||
|
- Additional LIKE queries on `custom_field_values` for substring matching:
|
||||||
|
```sql
|
||||||
|
EXISTS (SELECT 1 FROM custom_field_values WHERE member_id = id AND ... LIKE ...)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Performance Impact:**
|
||||||
|
- ✅ **Good:** GIN index on `search_vector` is very fast
|
||||||
|
- ⚠️ **Potential Issue:** LIKE queries on JSONB are not indexed (sequential scan)
|
||||||
|
- ⚠️ **Potential Issue:** EXISTS subquery runs for every search, even if search_vector match is found
|
||||||
|
- ⚠️ **Potential Issue:** With many custom fields, the LIKE queries could be slow
|
||||||
|
|
||||||
|
**Expected Performance:**
|
||||||
|
- **With GIN index match:** Very fast (< 10ms for typical queries)
|
||||||
|
- **Without GIN index match (fallback to LIKE):** Slower (10-100ms depending on data size)
|
||||||
|
- **Worst case:** Sequential scan of all custom_field_values for all members
|
||||||
|
|
||||||
|
## Recommendations
|
||||||
|
|
||||||
|
### Short-term (Current Implementation)
|
||||||
|
|
||||||
|
1. **Monitor Performance:**
|
||||||
|
- Add logging for trigger execution time
|
||||||
|
- Monitor search_vector size distribution
|
||||||
|
- Track search query performance
|
||||||
|
|
||||||
|
2. **Index Verification:**
|
||||||
|
- Ensure `custom_field_values_member_id_idx` exists and is used
|
||||||
|
- Verify GIN index on `search_vector` is maintained
|
||||||
|
|
||||||
|
3. **Bulk Operations:**
|
||||||
|
- For bulk imports, consider temporarily disabling the custom_field_values trigger
|
||||||
|
- Re-enable and update search_vectors in batch after import
|
||||||
|
|
||||||
|
### Medium-term Optimizations
|
||||||
|
|
||||||
|
1. **✅ Optimize Trigger Function (FULLY IMPLEMENTED):**
|
||||||
|
- ✅ Only fetch required member fields instead of full record (reduces overhead)
|
||||||
|
- ✅ Skip re-aggregation on UPDATE if value hasn't actually changed (early return optimization)
|
||||||
|
|
||||||
|
2. **Limit Search Vector Size:**
|
||||||
|
- Truncate very long custom field values (e.g., first 1000 chars)
|
||||||
|
- Add warning if aggregated text exceeds threshold
|
||||||
|
|
||||||
|
3. **Optimize LIKE Queries:**
|
||||||
|
- Consider adding a generated column for searchable text
|
||||||
|
- Or use a materialized view for custom field search
|
||||||
|
|
||||||
|
### Long-term Considerations
|
||||||
|
|
||||||
|
1. **Alternative Approaches:**
|
||||||
|
- Separate search index table for custom fields
|
||||||
|
- Use Elasticsearch or similar for advanced search
|
||||||
|
- Materialized view for search optimization
|
||||||
|
|
||||||
|
2. **Scaling Strategy:**
|
||||||
|
- If performance becomes an issue with 100+ custom fields per member:
|
||||||
|
- Consider limiting which custom fields are searchable
|
||||||
|
- Use a separate search service
|
||||||
|
- Implement search result caching
|
||||||
|
|
||||||
|
## Performance Benchmarks (Estimated)
|
||||||
|
|
||||||
|
Based on typical PostgreSQL performance:
|
||||||
|
|
||||||
|
| Scenario | Members | Custom Fields/Member | Expected Impact |
|
||||||
|
|----------|---------|---------------------|-----------------|
|
||||||
|
| Small | < 1,000 | < 10 | Negligible (< 5ms per operation) |
|
||||||
|
| Medium | 1,000-10,000 | 10-30 | Minor (5-20ms per operation) |
|
||||||
|
| Large | 10,000-100,000 | 30-50 | Noticeable (20-50ms per operation) |
|
||||||
|
| Very Large | > 100,000 | 50+ | Significant (50-200ms+ per operation) |
|
||||||
|
|
||||||
|
## Monitoring Queries
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- Check search_vector size distribution
|
||||||
|
SELECT
|
||||||
|
pg_size_pretty(octet_length(search_vector::text)) as size,
|
||||||
|
COUNT(*) as member_count
|
||||||
|
FROM members
|
||||||
|
WHERE search_vector IS NOT NULL
|
||||||
|
GROUP BY octet_length(search_vector::text)
|
||||||
|
ORDER BY octet_length(search_vector::text) DESC
|
||||||
|
LIMIT 20;
|
||||||
|
|
||||||
|
-- Check average custom fields per member
|
||||||
|
SELECT
|
||||||
|
AVG(custom_field_count) as avg_custom_fields,
|
||||||
|
MAX(custom_field_count) as max_custom_fields
|
||||||
|
FROM (
|
||||||
|
SELECT member_id, COUNT(*) as custom_field_count
|
||||||
|
FROM custom_field_values
|
||||||
|
GROUP BY member_id
|
||||||
|
) subq;
|
||||||
|
|
||||||
|
-- Check trigger execution time (requires pg_stat_statements)
|
||||||
|
SELECT
|
||||||
|
mean_exec_time,
|
||||||
|
calls,
|
||||||
|
query
|
||||||
|
FROM pg_stat_statements
|
||||||
|
WHERE query LIKE '%members_search_vector_trigger%'
|
||||||
|
ORDER BY mean_exec_time DESC;
|
||||||
|
```
|
||||||
|
|
||||||
|
## Code Quality Improvements (Post-Review)
|
||||||
|
|
||||||
|
### Refactored Search Implementation
|
||||||
|
|
||||||
|
The search query has been refactored for better maintainability and clarity:
|
||||||
|
|
||||||
|
**Before:** Single large OR-chain with mixed search types (hard to maintain)
|
||||||
|
|
||||||
|
**After:** Modular functions grouped by search type:
|
||||||
|
- `build_fts_filter/1` - Full-text search (highest priority, fastest)
|
||||||
|
- `build_substring_filter/2` - Substring matching on structured fields
|
||||||
|
- `build_custom_field_filter/1` - Custom field value search (JSONB LIKE)
|
||||||
|
- `build_fuzzy_filter/2` - Trigram/fuzzy matching for names and streets
|
||||||
|
|
||||||
|
**Benefits:**
|
||||||
|
- ✅ Clear separation of concerns
|
||||||
|
- ✅ Easier to maintain and test
|
||||||
|
- ✅ Better documentation of search priority
|
||||||
|
- ✅ Easier to optimize individual search types
|
||||||
|
|
||||||
|
**Search Priority Order:**
|
||||||
|
1. **FTS (Full-Text Search)** - Fastest, uses GIN index on search_vector
|
||||||
|
2. **Substring** - For structured fields (postal_code, phone_number, etc.)
|
||||||
|
3. **Custom Fields** - JSONB LIKE queries (fallback for substring matching)
|
||||||
|
4. **Fuzzy Matching** - Trigram similarity for names and streets
|
||||||
|
|
||||||
|
## Conclusion
|
||||||
|
|
||||||
|
The current implementation is **well-optimized for typical use cases** (< 30 custom fields per member, < 10,000 members). For larger scales, monitoring and potential optimizations may be needed.
|
||||||
|
|
||||||
|
**Key Strengths:**
|
||||||
|
- Indexed lookups (member_id index)
|
||||||
|
- Efficient GIN index for search
|
||||||
|
- Trigger-based automatic updates
|
||||||
|
- Modular, maintainable search code structure
|
||||||
|
|
||||||
|
**Key Weaknesses:**
|
||||||
|
- LIKE queries on JSONB (not indexed)
|
||||||
|
- Re-aggregation on every custom field change (necessary for consistency)
|
||||||
|
- Potential size issues with many/large custom fields
|
||||||
|
- Substring searches (contains/ILIKE) not index-optimized
|
||||||
|
|
||||||
|
**Recent Optimizations:**
|
||||||
|
- ✅ Trigger function optimized to fetch only required fields (reduces overhead by ~30-50%)
|
||||||
|
- ✅ Early return on UPDATE when value hasn't changed (skips expensive re-aggregation, <1ms vs 3-10ms)
|
||||||
|
- ✅ Improved performance for custom field value updates (3-10ms vs 5-15ms when value changes)
|
||||||
|
|
||||||
|
|
@ -168,9 +168,16 @@ Member (1) → (N) Properties
|
||||||
### Weighted Fields
|
### Weighted Fields
|
||||||
- **Weight A (highest):** first_name, last_name
|
- **Weight A (highest):** first_name, last_name
|
||||||
- **Weight B:** email, notes
|
- **Weight B:** email, notes
|
||||||
- **Weight C:** phone_number, city, street, house_number, postal_code
|
- **Weight C:** phone_number, city, street, house_number, postal_code, custom_field_values
|
||||||
- **Weight D (lowest):** join_date, exit_date
|
- **Weight D (lowest):** join_date, exit_date
|
||||||
|
|
||||||
|
### Custom Field Values in Search
|
||||||
|
Custom field values are automatically included in the search vector:
|
||||||
|
- All custom field values (string, integer, boolean, date, email) are aggregated and added to the search vector
|
||||||
|
- Values are converted to text format for indexing
|
||||||
|
- Custom field values receive weight 'C' (same as phone_number, city, etc.)
|
||||||
|
- The search vector is automatically updated when custom field values are created, updated, or deleted via database triggers
|
||||||
|
|
||||||
### Usage Example
|
### Usage Example
|
||||||
```sql
|
```sql
|
||||||
SELECT * FROM members
|
SELECT * FROM members
|
||||||
|
|
|
||||||
|
|
@ -187,10 +187,16 @@
|
||||||
|
|
||||||
**Current State:**
|
**Current State:**
|
||||||
- ✅ Basic "paid" boolean field on members
|
- ✅ Basic "paid" boolean field on members
|
||||||
|
- ✅ **UI Mock-ups for Membership Fee Types & Settings** (2025-12-02)
|
||||||
- ⚠️ No payment tracking
|
- ⚠️ No payment tracking
|
||||||
|
|
||||||
**Open Issues:**
|
**Open Issues:**
|
||||||
- [#156](https://git.local-it.org/local-it/mitgliederverwaltung/issues/156) - Set up & document testing environment for vereinfacht.digital (L, Low priority)
|
- [#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/Membership Fee Mockup Pages (Preview)
|
||||||
|
|
||||||
|
**Mock-Up Pages (Non-Functional Preview):**
|
||||||
|
- `/membership_fee_types` - Membership Fee Types Management
|
||||||
|
- `/membership_fee_settings` - Global Membership Fee Settings
|
||||||
|
|
||||||
**Missing Features:**
|
**Missing Features:**
|
||||||
- ❌ Membership fee configuration
|
- ❌ Membership fee configuration
|
||||||
|
|
|
||||||
712
docs/membership-fee-architecture.md
Normal file
712
docs/membership-fee-architecture.md
Normal file
|
|
@ -0,0 +1,712 @@
|
||||||
|
# Membership Fees - Technical Architecture
|
||||||
|
|
||||||
|
**Project:** Mila - Membership Management System
|
||||||
|
**Feature:** Membership Fee Management
|
||||||
|
**Version:** 1.0
|
||||||
|
**Last Updated:** 2025-11-27
|
||||||
|
**Status:** Architecture Design - Ready for Implementation
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
This document defines the technical architecture for the Membership Fees system. It focuses on architectural decisions, patterns, module structure, and integration points **without** concrete implementation details.
|
||||||
|
|
||||||
|
**Related Documents:**
|
||||||
|
|
||||||
|
- [membership-fee-overview.md](./membership-fee-overview.md) - Business logic and requirements
|
||||||
|
- [database-schema-readme.md](./database-schema-readme.md) - Database documentation
|
||||||
|
- [database_schema.dbml](./database_schema.dbml) - Database schema definition
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
|
||||||
|
1. [Architecture Principles](#architecture-principles)
|
||||||
|
2. [Domain Structure](#domain-structure)
|
||||||
|
3. [Data Architecture](#data-architecture)
|
||||||
|
4. [Business Logic Architecture](#business-logic-architecture)
|
||||||
|
5. [Integration Points](#integration-points)
|
||||||
|
6. [Acceptance Criteria](#acceptance-criteria)
|
||||||
|
7. [Testing Strategy](#testing-strategy)
|
||||||
|
8. [Security Considerations](#security-considerations)
|
||||||
|
9. [Performance Considerations](#performance-considerations)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture Principles
|
||||||
|
|
||||||
|
### Core Design Decisions
|
||||||
|
|
||||||
|
1. **Single Responsibility:**
|
||||||
|
- Each module has one clear responsibility
|
||||||
|
- Cycle generation separated from status management
|
||||||
|
- Calendar logic isolated in dedicated module
|
||||||
|
|
||||||
|
2. **No Redundancy:**
|
||||||
|
- No `cycle_end` field (calculated from `cycle_start` + `interval`)
|
||||||
|
- No `interval_type` field (read from `membership_fee_type.interval`)
|
||||||
|
- Eliminates data inconsistencies
|
||||||
|
|
||||||
|
3. **Immutability Where Important:**
|
||||||
|
- `membership_fee_type.interval` cannot be changed after creation
|
||||||
|
- Prevents complex migration scenarios
|
||||||
|
- Enforced via Ash change validation
|
||||||
|
|
||||||
|
4. **Historical Accuracy:**
|
||||||
|
- `amount` stored per cycle for audit trail
|
||||||
|
- Enables tracking of membership fee changes over time
|
||||||
|
- Old cycles retain original amounts
|
||||||
|
|
||||||
|
5. **Calendar-Based Cycles:**
|
||||||
|
- All cycles aligned to calendar boundaries
|
||||||
|
- Simplifies date calculations
|
||||||
|
- Predictable cycle generation
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Domain Structure
|
||||||
|
|
||||||
|
### Ash Domain: `Mv.MembershipFees`
|
||||||
|
|
||||||
|
**Purpose:** Encapsulates all membership fee-related resources and logic
|
||||||
|
|
||||||
|
**Resources:**
|
||||||
|
|
||||||
|
- `MembershipFeeType` - Membership fee type definitions (admin-managed)
|
||||||
|
- `MembershipFeeCycle` - Individual membership fee cycles per member
|
||||||
|
|
||||||
|
**Extensions:**
|
||||||
|
|
||||||
|
- Member resource extended with membership fee fields
|
||||||
|
|
||||||
|
### Module Organization
|
||||||
|
|
||||||
|
```
|
||||||
|
lib/
|
||||||
|
├── membership_fees/
|
||||||
|
│ ├── membership_fees.ex # Ash domain definition
|
||||||
|
│ ├── membership_fee_type.ex # MembershipFeeType resource
|
||||||
|
│ ├── membership_fee_cycle.ex # MembershipFeeCycle resource
|
||||||
|
│ └── changes/
|
||||||
|
│ ├── prevent_interval_change.ex # Validates interval immutability
|
||||||
|
│ ├── set_membership_fee_start_date.ex # Auto-sets start date
|
||||||
|
│ └── validate_same_interval.ex # Validates interval match on type change
|
||||||
|
├── mv/
|
||||||
|
│ └── membership_fees/
|
||||||
|
│ ├── cycle_generator.ex # Cycle generation algorithm
|
||||||
|
│ └── calendar_cycles.ex # Calendar cycle calculations
|
||||||
|
└── membership/
|
||||||
|
└── member.ex # Extended with membership fee relationships
|
||||||
|
```
|
||||||
|
|
||||||
|
### Separation of Concerns
|
||||||
|
|
||||||
|
**Domain Layer (Ash Resources):**
|
||||||
|
|
||||||
|
- Data validation
|
||||||
|
- Relationship management
|
||||||
|
- Policy enforcement
|
||||||
|
- Action definitions
|
||||||
|
|
||||||
|
**Business Logic Layer (`Mv.MembershipFees`):**
|
||||||
|
|
||||||
|
- Cycle generation algorithm
|
||||||
|
- Calendar calculations
|
||||||
|
- Date boundary handling
|
||||||
|
- Status transitions
|
||||||
|
|
||||||
|
**UI Layer (LiveView):**
|
||||||
|
|
||||||
|
- User interaction
|
||||||
|
- Display logic
|
||||||
|
- Authorization checks
|
||||||
|
- Form handling
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Data Architecture
|
||||||
|
|
||||||
|
### Database Schema Extensions
|
||||||
|
|
||||||
|
**See:** [database-schema-readme.md](./database-schema-readme.md) and [database_schema.dbml](./database_schema.dbml) for complete schema documentation.
|
||||||
|
|
||||||
|
### New Tables
|
||||||
|
|
||||||
|
1. **`membership_fee_types`**
|
||||||
|
- Purpose: Define membership fee types with fixed intervals
|
||||||
|
- Key Constraint: `interval` field immutable after creation
|
||||||
|
- Relationships: has_many members, has_many membership_fee_cycles
|
||||||
|
|
||||||
|
2. **`membership_fee_cycles`**
|
||||||
|
- Purpose: Individual membership fee cycles for members
|
||||||
|
- Key Design: NO `cycle_end` or `interval_type` fields (calculated)
|
||||||
|
- Relationships: belongs_to member, belongs_to membership_fee_type
|
||||||
|
- Composite uniqueness: One cycle per member per cycle_start
|
||||||
|
|
||||||
|
### Member Table Extensions
|
||||||
|
|
||||||
|
**Fields Added:**
|
||||||
|
|
||||||
|
- `membership_fee_type_id` (FK, NOT NULL with default from settings)
|
||||||
|
- `membership_fee_start_date` (Date, nullable)
|
||||||
|
|
||||||
|
**Existing Fields Used:**
|
||||||
|
|
||||||
|
- `joined_at` - For calculating membership fee start
|
||||||
|
- `left_at` - For limiting cycle generation
|
||||||
|
- These fields must remain member fields and should not be replaced by custom fields in the future
|
||||||
|
|
||||||
|
### Settings Integration
|
||||||
|
|
||||||
|
**Global Settings:**
|
||||||
|
|
||||||
|
- `membership_fees.include_joining_cycle` (Boolean)
|
||||||
|
- `membership_fees.default_membership_fee_type_id` (UUID)
|
||||||
|
|
||||||
|
**Storage:** Existing settings mechanism (TBD: dedicated table or configuration resource)
|
||||||
|
|
||||||
|
### Foreign Key Behaviors
|
||||||
|
|
||||||
|
| Relationship | On Delete | Rationale |
|
||||||
|
|--------------|-----------|-----------|
|
||||||
|
| `membership_fee_cycles.member_id → members.id` | CASCADE | Remove membership fee cycles when member deleted |
|
||||||
|
| `membership_fee_cycles.membership_fee_type_id → membership_fee_types.id` | RESTRICT | Prevent membership fee type deletion if cycles exist |
|
||||||
|
| `members.membership_fee_type_id → membership_fee_types.id` | RESTRICT | Prevent membership fee type deletion if assigned to members |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Business Logic Architecture
|
||||||
|
|
||||||
|
### Cycle Generation System
|
||||||
|
|
||||||
|
**Component:** `Mv.MembershipFees.CycleGenerator`
|
||||||
|
|
||||||
|
**Responsibilities:**
|
||||||
|
|
||||||
|
- Calculate which cycles should exist for a member
|
||||||
|
- Generate missing cycles
|
||||||
|
- Respect membership_fee_start_date and left_at boundaries
|
||||||
|
- Skip existing cycles (idempotent)
|
||||||
|
|
||||||
|
**Triggers:**
|
||||||
|
|
||||||
|
1. Member membership fee type assigned (via Ash change)
|
||||||
|
2. Member created with membership fee type (via Ash change)
|
||||||
|
3. Scheduled job runs (daily/weekly cron)
|
||||||
|
4. Admin manual regeneration (UI action)
|
||||||
|
|
||||||
|
**Algorithm Steps:**
|
||||||
|
|
||||||
|
1. Retrieve member with membership fee type and dates
|
||||||
|
2. Determine first cycle start (based on membership_fee_start_date)
|
||||||
|
3. Calculate all cycle starts from first to today (or left_at)
|
||||||
|
4. Query existing cycles for member
|
||||||
|
5. Generate missing cycles with current membership fee type's amount
|
||||||
|
6. Insert new cycles (batch operation)
|
||||||
|
|
||||||
|
**Edge Case Handling:**
|
||||||
|
|
||||||
|
- If membership_fee_start_date is NULL: Calculate from joined_at + global setting
|
||||||
|
- If left_at is set: Stop generation at left_at
|
||||||
|
- If membership fee type changes: Handled separately by regeneration logic
|
||||||
|
|
||||||
|
### Calendar Cycle Calculations
|
||||||
|
|
||||||
|
**Component:** `Mv.MembershipFees.CalendarCycles`
|
||||||
|
|
||||||
|
**Responsibilities:**
|
||||||
|
|
||||||
|
- Calculate cycle boundaries based on interval type
|
||||||
|
- Determine current cycle
|
||||||
|
- Determine last completed cycle
|
||||||
|
- Calculate cycle_end from cycle_start + interval
|
||||||
|
|
||||||
|
**Functions (high-level):**
|
||||||
|
|
||||||
|
- `calculate_cycle_start/3` - Given date and interval, find cycle start
|
||||||
|
- `calculate_cycle_end/2` - Given cycle_start and interval, calculate end
|
||||||
|
- `next_cycle_start/2` - Given cycle_start and interval, find next
|
||||||
|
- `is_current_cycle?/2` - Check if cycle contains today
|
||||||
|
- `is_last_completed_cycle?/2` - Check if cycle just ended
|
||||||
|
|
||||||
|
**Interval Logic:**
|
||||||
|
|
||||||
|
- **Monthly:** Start = 1st of month, End = last day of month
|
||||||
|
- **Quarterly:** Start = 1st of quarter (Jan/Apr/Jul/Oct), End = last day of quarter
|
||||||
|
- **Half-yearly:** Start = 1st of half (Jan/Jul), End = last day of half
|
||||||
|
- **Yearly:** Start = Jan 1st, End = Dec 31st
|
||||||
|
|
||||||
|
### Status Management
|
||||||
|
|
||||||
|
**Component:** Ash actions on `MembershipFeeCycle`
|
||||||
|
|
||||||
|
**Status Transitions:**
|
||||||
|
|
||||||
|
- Simple state machine: unpaid ↔ paid ↔ suspended
|
||||||
|
- No complex validation (all transitions allowed)
|
||||||
|
- Permissions checked via Ash policies
|
||||||
|
|
||||||
|
**Actions Required:**
|
||||||
|
|
||||||
|
- `mark_as_paid` - Set status to :paid
|
||||||
|
- `mark_as_suspended` - Set status to :suspended
|
||||||
|
- `mark_as_unpaid` - Set status to :unpaid (error correction)
|
||||||
|
|
||||||
|
**Bulk Operations:**
|
||||||
|
|
||||||
|
- `bulk_mark_as_paid` - Mark multiple cycles as paid (efficiency)
|
||||||
|
- low priority, can be a future issue
|
||||||
|
|
||||||
|
### Membership Fee Type Change Handling
|
||||||
|
|
||||||
|
**Component:** Ash change on `Member.membership_fee_type_id`
|
||||||
|
|
||||||
|
**Validation:**
|
||||||
|
|
||||||
|
- Check if new type has same interval as old type
|
||||||
|
- If different: Reject change (MVP constraint)
|
||||||
|
- If same: Allow change
|
||||||
|
|
||||||
|
**Side Effects on Allowed Change:**
|
||||||
|
|
||||||
|
1. Keep all existing cycles unchanged
|
||||||
|
2. Find future unpaid cycles
|
||||||
|
3. Delete future unpaid cycles
|
||||||
|
4. Regenerate cycles with new membership_fee_type_id and amount
|
||||||
|
|
||||||
|
**Implementation Pattern:**
|
||||||
|
|
||||||
|
- Use Ash change module to validate
|
||||||
|
- Use after_action hook to trigger regeneration
|
||||||
|
- Use transaction to ensure atomicity
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Integration Points
|
||||||
|
|
||||||
|
### Member Resource Integration
|
||||||
|
|
||||||
|
**Extension Points:**
|
||||||
|
|
||||||
|
1. Add fields via migration
|
||||||
|
2. Add relationships (belongs_to, has_many)
|
||||||
|
3. Add calculations (current_cycle_status, overdue_count)
|
||||||
|
4. Add changes (auto-set membership_fee_start_date, validate interval)
|
||||||
|
|
||||||
|
**Backward Compatibility:**
|
||||||
|
|
||||||
|
- New fields nullable or with defaults
|
||||||
|
- Existing members get default membership fee type from settings
|
||||||
|
- No breaking changes to existing member functionality
|
||||||
|
|
||||||
|
### Settings System Integration
|
||||||
|
|
||||||
|
**Requirements:**
|
||||||
|
|
||||||
|
- Store two global settings
|
||||||
|
- Provide UI for admin to modify
|
||||||
|
- Default values if not set
|
||||||
|
- Validation (e.g., default membership fee type must exist)
|
||||||
|
|
||||||
|
**Access Pattern:**
|
||||||
|
|
||||||
|
- Read settings during cycle generation
|
||||||
|
- Read settings during member creation
|
||||||
|
- Write settings only via admin UI
|
||||||
|
|
||||||
|
### Permission System Integration
|
||||||
|
|
||||||
|
**See:** [roles-and-permissions-architecture.md](./roles-and-permissions-architecture.md)
|
||||||
|
|
||||||
|
**Required Permissions:**
|
||||||
|
|
||||||
|
- `MembershipFeeType.create/update/destroy` - Admin only
|
||||||
|
- `MembershipFeeType.read` - Admin, Treasurer, Board
|
||||||
|
- `MembershipFeeCycle.update` (status changes) - Admin, Treasurer
|
||||||
|
- `MembershipFeeCycle.read` - Admin, Treasurer, Board, Own member
|
||||||
|
|
||||||
|
**Policy Patterns:**
|
||||||
|
|
||||||
|
- Use existing HasPermission check
|
||||||
|
- Leverage existing roles (Admin, Kassenwart)
|
||||||
|
- Member can read own cycles (linked via member_id)
|
||||||
|
|
||||||
|
### LiveView Integration
|
||||||
|
|
||||||
|
**New LiveViews Required:**
|
||||||
|
|
||||||
|
1. MembershipFeeType index/form (admin)
|
||||||
|
2. MembershipFeeCycle table component (member detail view)
|
||||||
|
3. Settings form section (admin)
|
||||||
|
4. Member list column (membership fee status)
|
||||||
|
|
||||||
|
**Existing LiveViews to Extend:**
|
||||||
|
|
||||||
|
- Member detail view: Add membership fees section
|
||||||
|
- Member list view: Add status column
|
||||||
|
- Settings page: Add membership fees section
|
||||||
|
|
||||||
|
**Authorization Helpers:**
|
||||||
|
|
||||||
|
- Use existing `can?/3` helper for UI conditionals
|
||||||
|
- Check permissions before showing actions
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
### MembershipFeeType Resource
|
||||||
|
|
||||||
|
**AC-MFT-1:** Admin can create membership fee type with name, amount, interval, description
|
||||||
|
**AC-MFT-2:** Interval field is immutable after creation (validation error on change attempt)
|
||||||
|
**AC-MFT-3:** Admin can update name, amount, description (but not interval)
|
||||||
|
**AC-MFT-4:** Cannot delete membership fee type if assigned to members
|
||||||
|
**AC-MFT-5:** Cannot delete membership fee type if cycles exist referencing it
|
||||||
|
**AC-MFT-6:** Interval must be one of: monthly, quarterly, half_yearly, yearly
|
||||||
|
|
||||||
|
### MembershipFeeCycle Resource
|
||||||
|
|
||||||
|
**AC-MFC-1:** Cycle has cycle_start, status, amount, notes, member_id, membership_fee_type_id
|
||||||
|
**AC-MFC-2:** cycle_end is calculated, not stored
|
||||||
|
**AC-MFC-3:** Status defaults to :unpaid
|
||||||
|
**AC-MFC-4:** One cycle per member per cycle_start (uniqueness constraint)
|
||||||
|
**AC-MFC-5:** Amount is set at generation time from membership_fee_type.amount
|
||||||
|
**AC-MFC-6:** Cycles cascade delete when member deleted
|
||||||
|
**AC-MFC-7:** Admin/Treasurer can change status
|
||||||
|
**AC-MFC-8:** Member can read own cycles
|
||||||
|
|
||||||
|
### Member Extensions
|
||||||
|
|
||||||
|
**AC-M-1:** Member has membership_fee_type_id field (NOT NULL with default)
|
||||||
|
**AC-M-2:** Member has membership_fee_start_date field (nullable)
|
||||||
|
**AC-M-3:** New members get default membership fee type from global setting
|
||||||
|
**AC-M-4:** membership_fee_start_date auto-set based on joined_at and global setting
|
||||||
|
**AC-M-5:** Admin can manually override membership_fee_start_date
|
||||||
|
**AC-M-6:** Cannot change to membership fee type with different interval (MVP)
|
||||||
|
|
||||||
|
### Cycle Generation
|
||||||
|
|
||||||
|
**AC-CG-1:** Cycles generated when member gets membership fee type
|
||||||
|
**AC-CG-2:** Cycles generated when member created (via change hook)
|
||||||
|
**AC-CG-3:** Scheduled job generates missing cycles daily
|
||||||
|
**AC-CG-4:** Generation respects membership_fee_start_date
|
||||||
|
**AC-CG-5:** Generation stops at left_at if member exited
|
||||||
|
**AC-CG-6:** Generation is idempotent (skips existing cycles)
|
||||||
|
**AC-CG-7:** Cycles align to calendar boundaries (1st of month/quarter/half/year)
|
||||||
|
**AC-CG-8:** Amount comes from membership_fee_type at generation time
|
||||||
|
|
||||||
|
### Calendar Logic
|
||||||
|
|
||||||
|
**AC-CL-1:** Monthly cycles: 1st to last day of month
|
||||||
|
**AC-CL-2:** Quarterly cycles: 1st of Jan/Apr/Jul/Oct to last day of quarter
|
||||||
|
**AC-CL-3:** Half-yearly cycles: 1st of Jan/Jul to last day of half
|
||||||
|
**AC-CL-4:** Yearly cycles: Jan 1 to Dec 31
|
||||||
|
**AC-CL-5:** cycle_end calculated correctly for all interval types
|
||||||
|
**AC-CL-6:** Current cycle determined correctly based on today's date
|
||||||
|
**AC-CL-7:** Last completed cycle determined correctly
|
||||||
|
|
||||||
|
### Membership Fee Type Change
|
||||||
|
|
||||||
|
**AC-TC-1:** Can change to type with same interval
|
||||||
|
**AC-TC-2:** Cannot change to type with different interval (error message)
|
||||||
|
**AC-TC-3:** On allowed change: future unpaid cycles regenerated
|
||||||
|
**AC-TC-4:** On allowed change: paid/suspended cycles unchanged
|
||||||
|
**AC-TC-5:** On allowed change: amount updated to new type's amount
|
||||||
|
**AC-TC-6:** Change is atomic (transaction)
|
||||||
|
|
||||||
|
### Settings
|
||||||
|
|
||||||
|
**AC-S-1:** Global setting: include_joining_cycle (boolean, default true)
|
||||||
|
**AC-S-2:** Global setting: default_membership_fee_type_id (UUID, required)
|
||||||
|
**AC-S-3:** Admin can modify settings via UI
|
||||||
|
**AC-S-4:** Settings validated (e.g., default membership fee type must exist)
|
||||||
|
**AC-S-5:** Settings applied to new members immediately
|
||||||
|
|
||||||
|
### UI - Member List
|
||||||
|
|
||||||
|
**AC-UI-ML-1:** New column shows membership fee status
|
||||||
|
**AC-UI-ML-2:** Default: Shows last completed cycle status
|
||||||
|
**AC-UI-ML-3:** Optional: Toggle to show current cycle status
|
||||||
|
**AC-UI-ML-4:** Color coding: green (paid), red (unpaid), gray (suspended)
|
||||||
|
**AC-UI-ML-5:** Filter: Unpaid in last cycle
|
||||||
|
**AC-UI-ML-6:** Filter: Unpaid in current cycle
|
||||||
|
|
||||||
|
### UI - Member Detail
|
||||||
|
|
||||||
|
**AC-UI-MD-1:** Membership fees section shows all cycles
|
||||||
|
**AC-UI-MD-2:** Table columns: Cycle, Interval, Amount, Status, Actions
|
||||||
|
**AC-UI-MD-3:** Checkbox per cycle for bulk marking (low prio)
|
||||||
|
**AC-UI-MD-4:** "Mark selected as paid" button
|
||||||
|
**AC-UI-MD-5:** Dropdown to change membership fee type (same interval only)
|
||||||
|
**AC-UI-MD-6:** Warning if different interval selected
|
||||||
|
**AC-UI-MD-7:** Only show actions if user has permission
|
||||||
|
|
||||||
|
### UI - Membership Fee Types Admin
|
||||||
|
|
||||||
|
**AC-UI-CTA-1:** List all membership fee types
|
||||||
|
**AC-UI-CTA-2:** Show: Name, Amount, Interval, Member count
|
||||||
|
**AC-UI-CTA-3:** Create new membership fee type form
|
||||||
|
**AC-UI-CTA-4:** Edit form: Name, Amount, Description editable
|
||||||
|
**AC-UI-CTA-5:** Edit form: Interval grayed out (not editable)
|
||||||
|
**AC-UI-CTA-6:** Warning on amount change (explain impact)
|
||||||
|
**AC-UI-CTA-7:** Cannot delete if members assigned
|
||||||
|
**AC-UI-CTA-8:** Only admin can access
|
||||||
|
|
||||||
|
### UI - Settings Admin
|
||||||
|
|
||||||
|
**AC-UI-SA-1:** Membership fees section in settings
|
||||||
|
**AC-UI-SA-2:** Dropdown to select default membership fee type
|
||||||
|
**AC-UI-SA-3:** Checkbox: Include joining cycle
|
||||||
|
**AC-UI-SA-4:** Explanatory text with examples
|
||||||
|
**AC-UI-SA-5:** Save button with validation
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing Strategy
|
||||||
|
|
||||||
|
### Unit Testing
|
||||||
|
|
||||||
|
**Cycle Generator Tests:**
|
||||||
|
|
||||||
|
- Correct cycle_start calculation for all interval types
|
||||||
|
- Correct cycle count from start to end date
|
||||||
|
- Respects membership_fee_start_date boundary
|
||||||
|
- Respects left_at boundary
|
||||||
|
- Skips existing cycles (idempotent)
|
||||||
|
- Handles edge dates (year boundaries, leap years)
|
||||||
|
|
||||||
|
**Calendar Cycles Tests:**
|
||||||
|
|
||||||
|
- Cycle boundaries correct for all intervals
|
||||||
|
- cycle_end calculation correct
|
||||||
|
- Current cycle detection
|
||||||
|
- Last completed cycle detection
|
||||||
|
- Next cycle calculation
|
||||||
|
|
||||||
|
**Validation Tests:**
|
||||||
|
|
||||||
|
- Interval immutability enforced
|
||||||
|
- Same interval validation on type change
|
||||||
|
- Status transitions allowed
|
||||||
|
- Uniqueness constraints enforced
|
||||||
|
|
||||||
|
### Integration Testing
|
||||||
|
|
||||||
|
**Cycle Generation Flow:**
|
||||||
|
|
||||||
|
- Member creation triggers generation
|
||||||
|
- Type assignment triggers generation
|
||||||
|
- Type change regenerates future cycles
|
||||||
|
- Scheduled job generates missing cycles
|
||||||
|
- Left member stops generation
|
||||||
|
|
||||||
|
**Status Management Flow:**
|
||||||
|
|
||||||
|
- Mark single cycle as paid
|
||||||
|
- Bulk mark multiple cycles (low prio)
|
||||||
|
- Status transitions work
|
||||||
|
- Permissions enforced
|
||||||
|
|
||||||
|
**Membership Fee Type Management:**
|
||||||
|
|
||||||
|
- Create type
|
||||||
|
- Update amount (regeneration triggered)
|
||||||
|
- Cannot update interval
|
||||||
|
- Cannot delete if in use
|
||||||
|
|
||||||
|
### LiveView Testing
|
||||||
|
|
||||||
|
**Member List:**
|
||||||
|
|
||||||
|
- Status column displays correctly
|
||||||
|
- Toggle between last/current works
|
||||||
|
- Filters work correctly
|
||||||
|
- Color coding applied
|
||||||
|
|
||||||
|
**Member Detail:**
|
||||||
|
|
||||||
|
- Cycles table displays all cycles
|
||||||
|
- Checkboxes work
|
||||||
|
- Bulk marking works (low prio)
|
||||||
|
- Membership fee type change validation works
|
||||||
|
- Actions only shown with permission
|
||||||
|
|
||||||
|
**Admin UI:**
|
||||||
|
|
||||||
|
- Type CRUD works
|
||||||
|
- Settings save correctly
|
||||||
|
- Validations display errors
|
||||||
|
- Only authorized users can access
|
||||||
|
|
||||||
|
### Edge Case Testing
|
||||||
|
|
||||||
|
**Interval Change Attempt:**
|
||||||
|
|
||||||
|
- Error message displayed
|
||||||
|
- No data modified
|
||||||
|
- User can cancel/choose different type
|
||||||
|
|
||||||
|
**Exit with Unpaid:**
|
||||||
|
|
||||||
|
- Warning shown
|
||||||
|
- Option to suspend offered
|
||||||
|
- Exit completes correctly
|
||||||
|
|
||||||
|
**Amount Change:**
|
||||||
|
|
||||||
|
- Warning displayed
|
||||||
|
- Only future unpaid regenerated
|
||||||
|
- Historical cycles unchanged
|
||||||
|
|
||||||
|
**Date Boundaries:**
|
||||||
|
|
||||||
|
- Today = cycle start handled
|
||||||
|
- Today = cycle end handled
|
||||||
|
- Leap year handled
|
||||||
|
|
||||||
|
### Performance Testing
|
||||||
|
|
||||||
|
**Cycle Generation:**
|
||||||
|
|
||||||
|
- Generate 10 years of monthly cycles: < 100ms
|
||||||
|
- Generate for 1000 members: < 5 seconds
|
||||||
|
- Idempotent check efficient (no full scan)
|
||||||
|
|
||||||
|
**Member List Query:**
|
||||||
|
|
||||||
|
- With status column: < 200ms for 1000 members
|
||||||
|
- Filters applied efficiently
|
||||||
|
- No N+1 queries
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Security Considerations
|
||||||
|
|
||||||
|
### Authorization
|
||||||
|
|
||||||
|
**Permissions Required:**
|
||||||
|
|
||||||
|
- Membership fee type management: Admin only
|
||||||
|
- Membership fee cycle status changes: Admin + Treasurer
|
||||||
|
- View all cycles: Admin + Treasurer + Board
|
||||||
|
- View own cycles: All authenticated users
|
||||||
|
|
||||||
|
**Policy Enforcement:**
|
||||||
|
|
||||||
|
- All actions protected by Ash policies
|
||||||
|
- UI shows/hides based on permissions
|
||||||
|
- Backend validates permissions (never trust UI alone)
|
||||||
|
|
||||||
|
### Data Integrity
|
||||||
|
|
||||||
|
**Validation Layers:**
|
||||||
|
|
||||||
|
1. Database constraints (NOT NULL, UNIQUE, CHECK)
|
||||||
|
2. Ash validations (business rules)
|
||||||
|
3. UI validations (user experience)
|
||||||
|
|
||||||
|
**Immutability Protection:**
|
||||||
|
|
||||||
|
- Interval change prevented at multiple layers
|
||||||
|
- Cycle amounts immutable (audit trail)
|
||||||
|
- Settings changes logged (future)
|
||||||
|
|
||||||
|
### Audit Trail
|
||||||
|
|
||||||
|
**Tracked Information:**
|
||||||
|
|
||||||
|
- Cycle status changes (who, when) - future enhancement
|
||||||
|
- Membership fee type amount changes (implicit via cycle amounts)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Performance Considerations
|
||||||
|
|
||||||
|
### Database Indexes
|
||||||
|
|
||||||
|
**Required Indexes:**
|
||||||
|
|
||||||
|
- `membership_fee_cycles(member_id)` - For member cycle lookups
|
||||||
|
- `membership_fee_cycles(membership_fee_type_id)` - For type queries
|
||||||
|
- `membership_fee_cycles(status)` - For unpaid filters
|
||||||
|
- `membership_fee_cycles(cycle_start)` - For date range queries
|
||||||
|
- `membership_fee_cycles(member_id, cycle_start)` - Composite unique index
|
||||||
|
- `members(membership_fee_type_id)` - For type membership count
|
||||||
|
|
||||||
|
### Query Optimization
|
||||||
|
|
||||||
|
**Preloading:**
|
||||||
|
|
||||||
|
- Load membership_fee_type with cycles (avoid N+1)
|
||||||
|
- Load cycles when displaying member detail
|
||||||
|
- Use Ash's load for efficient preloading
|
||||||
|
|
||||||
|
**Calculated Fields:**
|
||||||
|
|
||||||
|
- cycle_end calculated on-demand (not stored)
|
||||||
|
- current_cycle_status calculated when needed
|
||||||
|
- Use Ash calculations for lazy evaluation
|
||||||
|
|
||||||
|
**Pagination:**
|
||||||
|
|
||||||
|
- Cycle list paginated if > 50 cycles
|
||||||
|
- Member list already paginated
|
||||||
|
|
||||||
|
### Caching Strategy
|
||||||
|
|
||||||
|
**No caching needed in MVP:**
|
||||||
|
|
||||||
|
- Membership fee types rarely change
|
||||||
|
- Cycle queries are fast
|
||||||
|
- Settings read infrequently
|
||||||
|
|
||||||
|
**Future caching if needed:**
|
||||||
|
|
||||||
|
- Cache settings in application memory
|
||||||
|
- Cache membership fee types list
|
||||||
|
- Invalidate on change
|
||||||
|
|
||||||
|
### Scheduled Job Performance
|
||||||
|
|
||||||
|
**Cycle Generation Job:**
|
||||||
|
|
||||||
|
- Run daily or weekly (not hourly)
|
||||||
|
- Batch members (process 100 at a time)
|
||||||
|
- Skip members with no changes
|
||||||
|
- Log failures for retry
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Future Enhancements
|
||||||
|
|
||||||
|
### Phase 2: Interval Change Support
|
||||||
|
|
||||||
|
**Architecture Changes:**
|
||||||
|
|
||||||
|
- Add logic to handle cycle overlaps
|
||||||
|
- Calculate prorata amounts if needed
|
||||||
|
- More complex validation
|
||||||
|
- Migration path for existing cycles
|
||||||
|
|
||||||
|
### Phase 3: Payment Details
|
||||||
|
|
||||||
|
**Architecture Changes:**
|
||||||
|
|
||||||
|
- Add PaymentTransaction resource
|
||||||
|
- Link transactions to cycles
|
||||||
|
- Support multiple payments per cycle
|
||||||
|
- Reconciliation logic
|
||||||
|
|
||||||
|
### Phase 4: vereinfacht.digital Integration
|
||||||
|
|
||||||
|
**Architecture Changes:**
|
||||||
|
|
||||||
|
- External API client module
|
||||||
|
- Webhook handling for transactions
|
||||||
|
- Automatic matching logic
|
||||||
|
- Manual review interface
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**End of Architecture Document**
|
||||||
530
docs/membership-fee-overview.md
Normal file
530
docs/membership-fee-overview.md
Normal file
|
|
@ -0,0 +1,530 @@
|
||||||
|
# Membership Fees - Overview
|
||||||
|
|
||||||
|
**Project:** Mila - Membership Management System
|
||||||
|
**Feature:** Membership Fee Management
|
||||||
|
**Version:** 1.0
|
||||||
|
**Last Updated:** 2025-11-27
|
||||||
|
**Status:** Concept - Ready for Review
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
This document provides a comprehensive overview of the Membership Fees system. It covers business logic, data model, UI/UX design, and technical architecture in a concise, bullet-point format.
|
||||||
|
|
||||||
|
**For detailed implementation:** See [membership-fee-implementation-plan.md](./membership-fee-implementation-plan.md) (created after concept iterations)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Table of Contents
|
||||||
|
|
||||||
|
1. [Core Principle](#core-principle)
|
||||||
|
2. [Terminology](#terminology)
|
||||||
|
3. [Data Model](#data-model)
|
||||||
|
4. [Business Logic](#business-logic)
|
||||||
|
5. [UI/UX Design](#uiux-design)
|
||||||
|
6. [Edge Cases](#edge-cases)
|
||||||
|
7. [Technical Integration](#technical-integration)
|
||||||
|
8. [Implementation Scope](#implementation-scope)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Core Principle
|
||||||
|
|
||||||
|
**Maximum Simplicity:**
|
||||||
|
|
||||||
|
- Minimal complexity
|
||||||
|
- Clear data model without redundancies
|
||||||
|
- Intuitive operation
|
||||||
|
- Calendar cycle-based (Month/Quarter/Half-Year/Year)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Terminology
|
||||||
|
|
||||||
|
### German ↔ English
|
||||||
|
|
||||||
|
**Core Entities:**
|
||||||
|
|
||||||
|
- Beitragsart ↔ Membership Fee Type
|
||||||
|
- Beitragszyklus ↔ Membership Fee Cycle
|
||||||
|
- Mitgliedsbeitrag ↔ Membership Fee
|
||||||
|
|
||||||
|
**Status:**
|
||||||
|
|
||||||
|
- bezahlt ↔ paid
|
||||||
|
- unbezahlt ↔ unpaid
|
||||||
|
- ausgesetzt ↔ suspended / waived
|
||||||
|
|
||||||
|
**Intervals (Frequenz / Payment Frequency):**
|
||||||
|
|
||||||
|
- monatlich ↔ monthly
|
||||||
|
- quartalsweise ↔ quarterly
|
||||||
|
- halbjährlich ↔ half-yearly / semi-annually
|
||||||
|
- jährlich ↔ yearly / annually
|
||||||
|
|
||||||
|
**UI Elements:**
|
||||||
|
|
||||||
|
- "Letzter Zyklus" ↔ "Last Cycle" (e.g., 2023 when in 2024)
|
||||||
|
- "Aktueller Zyklus" ↔ "Current Cycle" (e.g., 2024)
|
||||||
|
- "Als bezahlt markieren" ↔ "Mark as paid"
|
||||||
|
- "Aussetzen" ↔ "Suspend" / "Waive"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Data Model
|
||||||
|
|
||||||
|
### Membership Fee Type (MembershipFeeType)
|
||||||
|
|
||||||
|
```
|
||||||
|
- id (UUID)
|
||||||
|
- name (String) - e.g., "Regular", "Reduced", "Student"
|
||||||
|
- amount (Decimal) - Membership fee amount in Euro
|
||||||
|
- interval (Enum) - :monthly, :quarterly, :half_yearly, :yearly
|
||||||
|
- description (Text, optional)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Important:**
|
||||||
|
|
||||||
|
- `interval` is **IMMUTABLE** after creation!
|
||||||
|
- Admin can only change `name`, `amount`, `description`
|
||||||
|
- On change: Future unpaid cycles regenerated with new amount
|
||||||
|
|
||||||
|
### Membership Fee Cycle (MembershipFeeCycle)
|
||||||
|
|
||||||
|
```
|
||||||
|
- id (UUID)
|
||||||
|
- member_id (FK → members.id)
|
||||||
|
- membership_fee_type_id (FK → membership_fee_types.id)
|
||||||
|
- cycle_start (Date) - Calendar cycle start (01.01., 01.04., 01.07., 01.10., etc.)
|
||||||
|
- status (Enum) - :unpaid (default), :paid, :suspended
|
||||||
|
- amount (Decimal) - Membership fee amount at generation time (history when type changes)
|
||||||
|
- notes (Text, optional) - Admin notes
|
||||||
|
```
|
||||||
|
|
||||||
|
**Important:**
|
||||||
|
|
||||||
|
- **NO** `cycle_end` - calculated from `cycle_start` + `interval`
|
||||||
|
- **NO** `interval_type` - read from `membership_fee_type.interval`
|
||||||
|
- Avoids redundancy and inconsistencies!
|
||||||
|
|
||||||
|
**Calendar Cycle Logic:**
|
||||||
|
|
||||||
|
- Monthly: 01.01. - 31.01., 01.02. - 28./29.02., etc.
|
||||||
|
- Quarterly: 01.01. - 31.03., 01.04. - 30.06., 01.07. - 30.09., 01.10. - 31.12.
|
||||||
|
- Half-yearly: 01.01. - 30.06., 01.07. - 31.12.
|
||||||
|
- Yearly: 01.01. - 31.12.
|
||||||
|
|
||||||
|
### Member (Extensions)
|
||||||
|
|
||||||
|
```
|
||||||
|
- membership_fee_type_id (FK → membership_fee_types.id, NOT NULL, default from settings)
|
||||||
|
- membership_fee_start_date (Date, nullable) - When to start generating membership fees
|
||||||
|
- left_at (Date, nullable) - Exit date (existing)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Logic for membership_fee_start_date:**
|
||||||
|
|
||||||
|
- Auto-set based on global setting `include_joining_cycle`
|
||||||
|
- If `include_joining_cycle = true`: First day of joining month/quarter/year
|
||||||
|
- If `include_joining_cycle = false`: First day of NEXT cycle after joining
|
||||||
|
- Can be manually overridden by admin
|
||||||
|
|
||||||
|
**NO** `include_joining_cycle` field on Member - unnecessary due to `membership_fee_start_date`!
|
||||||
|
|
||||||
|
### Global Settings
|
||||||
|
|
||||||
|
```
|
||||||
|
key: "membership_fees.include_joining_cycle"
|
||||||
|
value: Boolean (Default: true)
|
||||||
|
|
||||||
|
key: "membership_fees.default_membership_fee_type_id"
|
||||||
|
value: UUID (Required) - Default membership fee type for new members
|
||||||
|
```
|
||||||
|
|
||||||
|
**Meaning include_joining_cycle:**
|
||||||
|
|
||||||
|
- `true`: Joining cycle is included (member pays from joining cycle)
|
||||||
|
- `false`: Only from next full cycle after joining
|
||||||
|
|
||||||
|
**Meaning of default membership fee type setting:**
|
||||||
|
|
||||||
|
- Every new member automatically gets this membership fee type
|
||||||
|
- Must be configured in admin settings
|
||||||
|
- Prevents: Members without membership fee type
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Business Logic
|
||||||
|
|
||||||
|
### Cycle Generation
|
||||||
|
|
||||||
|
**Triggers:**
|
||||||
|
|
||||||
|
- Member gets membership fee type assigned (also during member creation)
|
||||||
|
- New cycle begins (Cron job daily/weekly)
|
||||||
|
- Admin requests manual regeneration
|
||||||
|
|
||||||
|
**Algorithm:**
|
||||||
|
|
||||||
|
Lock the whole cycle table for the duration of the algorithm
|
||||||
|
|
||||||
|
1. Get `member.membership_fee_start_date` and member's membership fee type
|
||||||
|
2. Generate cycles until today (or `left_at` if present):
|
||||||
|
- If no cycle exists:
|
||||||
|
- Generate all cycles from `membership_fee_start_date`
|
||||||
|
- else:
|
||||||
|
- Generate all cycles from last existing cycle
|
||||||
|
- use the interval to generate the cycles
|
||||||
|
3. Set `amount` to current membership fee type's amount
|
||||||
|
|
||||||
|
**Example (Yearly):**
|
||||||
|
|
||||||
|
```
|
||||||
|
Joining date: 15.03.2023
|
||||||
|
include_joining_cycle: true
|
||||||
|
→ membership_fee_start_date: 01.01.2023
|
||||||
|
|
||||||
|
Generated cycles:
|
||||||
|
- 01.01.2023 - 31.12.2023 (joining cycle)
|
||||||
|
- 01.01.2024 - 31.12.2024
|
||||||
|
- 01.01.2025 - 31.12.2025 (current year)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Example (Quarterly):**
|
||||||
|
|
||||||
|
```
|
||||||
|
Joining date: 15.03.2023
|
||||||
|
include_joining_cycle: false
|
||||||
|
→ membership_fee_start_date: 01.04.2023
|
||||||
|
|
||||||
|
Generated cycles:
|
||||||
|
- 01.04.2023 - 30.06.2023 (first full quarter)
|
||||||
|
- 01.07.2023 - 30.09.2023
|
||||||
|
- 01.10.2023 - 31.12.2023
|
||||||
|
- 01.01.2024 - 31.03.2024
|
||||||
|
- ...
|
||||||
|
```
|
||||||
|
|
||||||
|
### Status Transitions
|
||||||
|
|
||||||
|
```
|
||||||
|
unpaid → paid
|
||||||
|
unpaid → suspended
|
||||||
|
paid → unpaid
|
||||||
|
suspended → paid
|
||||||
|
suspended → unpaid
|
||||||
|
```
|
||||||
|
|
||||||
|
**Permissions:**
|
||||||
|
|
||||||
|
- Admin + Treasurer (Kassenwart) can change status
|
||||||
|
- Uses existing permission system
|
||||||
|
|
||||||
|
### Membership Fee Type Change
|
||||||
|
|
||||||
|
**MVP - Same Cycle Only:**
|
||||||
|
|
||||||
|
- Member can only choose membership fee type with **same cycle**
|
||||||
|
- Example: From "Regular (yearly)" to "Reduced (yearly)" ✓
|
||||||
|
- Example: From "Regular (yearly)" to "Reduced (monthly)" ✗
|
||||||
|
|
||||||
|
**Logic on Change:**
|
||||||
|
|
||||||
|
1. Check: New membership fee type has same interval
|
||||||
|
2. If yes: Set `member.membership_fee_type_id`
|
||||||
|
3. Future **unpaid** cycles: Delete and regenerate with new amount
|
||||||
|
4. Paid/suspended cycles: Remain unchanged (historical amount)
|
||||||
|
|
||||||
|
**Future - Different Intervals:**
|
||||||
|
|
||||||
|
- Enable interval switching (e.g., yearly → monthly)
|
||||||
|
- More complex logic for cycle overlaps
|
||||||
|
- Needs additional validation
|
||||||
|
|
||||||
|
### Member Exit
|
||||||
|
|
||||||
|
**Logic:**
|
||||||
|
|
||||||
|
- Cycles only generated until `member.left_at`
|
||||||
|
- Existing cycles remain visible
|
||||||
|
- Unpaid exit cycle can be marked as "suspended"
|
||||||
|
|
||||||
|
**Example:**
|
||||||
|
|
||||||
|
```
|
||||||
|
Exit: 15.08.2024
|
||||||
|
Yearly cycle: 01.01.2024 - 31.12.2024
|
||||||
|
|
||||||
|
→ Cycle 2024 is shown (Status: unpaid)
|
||||||
|
→ Admin can set to "suspended"
|
||||||
|
→ No cycles for 2025+ generated
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## UI/UX Design
|
||||||
|
|
||||||
|
### Member List View
|
||||||
|
|
||||||
|
**New Column: "Membership Fee Status"**
|
||||||
|
|
||||||
|
**Default Display (Last Cycle):**
|
||||||
|
|
||||||
|
- Shows status of **last completed** cycle
|
||||||
|
- Example in 2024: Shows membership fee for 2023
|
||||||
|
- Color coding:
|
||||||
|
- Green: paid ✓
|
||||||
|
- Red: unpaid ✗
|
||||||
|
- Gray: suspended ⊘
|
||||||
|
|
||||||
|
**Optional: Show Current Cycle**
|
||||||
|
|
||||||
|
- Toggle: "Show current cycle" (2024)
|
||||||
|
- Admin decides what to display
|
||||||
|
|
||||||
|
**Filters:**
|
||||||
|
|
||||||
|
- "Unpaid membership fees in last cycle"
|
||||||
|
- "Unpaid membership fees in current cycle"
|
||||||
|
|
||||||
|
### Member Detail View
|
||||||
|
|
||||||
|
**Section: "Membership Fees"**
|
||||||
|
|
||||||
|
**Membership Fee Type Assignment:**
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────┐
|
||||||
|
│ Membership Fee Type: [Dropdown] │
|
||||||
|
│ ⚠ Only types with same interval │
|
||||||
|
│ can be selected │
|
||||||
|
└─────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**Cycle Table:**
|
||||||
|
|
||||||
|
```
|
||||||
|
┌───────────────┬──────────┬────────┬──────────┬─────────┐
|
||||||
|
│ Cycle │ Interval │ Amount │ Status │ Action │
|
||||||
|
├───────────────┼──────────┼────────┼──────────┼─────────┤
|
||||||
|
│ 01.01.2023- │ Yearly │ 50 € │ ☑ Paid │ │
|
||||||
|
│ 31.12.2023 │ │ │ │ │
|
||||||
|
├───────────────┼──────────┼────────┼──────────┼─────────┤
|
||||||
|
│ 01.01.2024- │ Yearly │ 60 € │ ☐ Open │ [Mark │
|
||||||
|
│ 31.12.2024 │ │ │ │ as paid]│
|
||||||
|
├───────────────┼──────────┼────────┼──────────┼─────────┤
|
||||||
|
│ 01.01.2025- │ Yearly │ 60 € │ ☐ Open │ [Mark │
|
||||||
|
│ 31.12.2025 │ │ │ │ as paid]│
|
||||||
|
└───────────────┴──────────┴────────┴──────────┴─────────┘
|
||||||
|
|
||||||
|
Legend: ☑ = paid | ☐ = unpaid | ⊘ = suspended
|
||||||
|
```
|
||||||
|
|
||||||
|
**Quick Marking:**
|
||||||
|
|
||||||
|
- Checkbox in each row for fast marking
|
||||||
|
- Button: "Mark selected as paid/unpaid/suspended"
|
||||||
|
- Bulk action for multiple cycles
|
||||||
|
|
||||||
|
### Admin: Membership Fee Types Management
|
||||||
|
|
||||||
|
**List:**
|
||||||
|
|
||||||
|
```
|
||||||
|
┌────────────┬──────────┬──────────┬────────────┬─────────┐
|
||||||
|
│ Name │ Amount │ Interval │ Members │ Actions │
|
||||||
|
├────────────┼──────────┼──────────┼────────────┼─────────┤
|
||||||
|
│ Regular │ 60 € │ Yearly │ 45 │ [Edit] │
|
||||||
|
│ Reduced │ 30 € │ Yearly │ 12 │ [Edit] │
|
||||||
|
│ Student │ 20 € │ Monthly │ 8 │ [Edit] │
|
||||||
|
└────────────┴──────────┴──────────┴────────────┴─────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**Edit:**
|
||||||
|
|
||||||
|
- Name: ✓ editable
|
||||||
|
- Amount: ✓ editable
|
||||||
|
- Description: ✓ editable
|
||||||
|
- Interval: ✗ **NOT** editable (grayed out)
|
||||||
|
|
||||||
|
**Warning on Amount Change:**
|
||||||
|
|
||||||
|
```
|
||||||
|
⚠ Change amount to 65 €?
|
||||||
|
|
||||||
|
Impact:
|
||||||
|
- 45 members affected
|
||||||
|
- Future unpaid cycles will be generated with 65 €
|
||||||
|
- Already paid cycles remain with old amount
|
||||||
|
|
||||||
|
[Cancel] [Confirm]
|
||||||
|
```
|
||||||
|
|
||||||
|
### Admin: Settings
|
||||||
|
|
||||||
|
**Membership Fee Configuration:**
|
||||||
|
|
||||||
|
```
|
||||||
|
Default Membership Fee Type: [Dropdown: Membership Fee Types]
|
||||||
|
|
||||||
|
Selected: "Regular (60 €, Yearly)"
|
||||||
|
|
||||||
|
This membership fee type is automatically assigned to all new members.
|
||||||
|
Can be changed individually per member.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
☐ Include joining cycle
|
||||||
|
|
||||||
|
When active:
|
||||||
|
Members pay from the cycle of their joining.
|
||||||
|
|
||||||
|
Example (Yearly):
|
||||||
|
Joining: 15.03.2023
|
||||||
|
→ Pays from 2023
|
||||||
|
|
||||||
|
When inactive:
|
||||||
|
Members pay from the next full cycle.
|
||||||
|
|
||||||
|
Example (Yearly):
|
||||||
|
Joining: 15.03.2023
|
||||||
|
→ Pays from 2024
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Edge Cases
|
||||||
|
|
||||||
|
### 1. Membership Fee Type Change with Different Interval
|
||||||
|
|
||||||
|
**MVP:** Blocked (only same interval allowed)
|
||||||
|
|
||||||
|
**UI:**
|
||||||
|
|
||||||
|
```
|
||||||
|
Error: Interval change not possible
|
||||||
|
|
||||||
|
Current membership fee type: "Regular (Yearly)"
|
||||||
|
Selected membership fee type: "Student (Monthly)"
|
||||||
|
|
||||||
|
Changing the interval is currently not possible.
|
||||||
|
Please select a membership fee type with interval "Yearly".
|
||||||
|
|
||||||
|
[OK]
|
||||||
|
```
|
||||||
|
|
||||||
|
**Future:**
|
||||||
|
|
||||||
|
- Allow interval switching
|
||||||
|
- Calculate overlaps
|
||||||
|
- Generate new cycles without duplicates
|
||||||
|
|
||||||
|
### 2. Exit with Unpaid Membership Fees
|
||||||
|
|
||||||
|
**Scenario:**
|
||||||
|
|
||||||
|
```
|
||||||
|
Member exits: 15.08.2024
|
||||||
|
Yearly cycle 2024: unpaid
|
||||||
|
```
|
||||||
|
|
||||||
|
**UI Notice on Exit: (Low Prio)**
|
||||||
|
|
||||||
|
```
|
||||||
|
⚠ Unpaid membership fees present
|
||||||
|
|
||||||
|
This member has 1 unpaid cycle(s):
|
||||||
|
- 2024: 60 € (unpaid)
|
||||||
|
|
||||||
|
Do you want to continue?
|
||||||
|
|
||||||
|
[ ] Mark membership fee as "suspended"
|
||||||
|
[Cancel] [Confirm Exit]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Multiple Unpaid Cycles
|
||||||
|
|
||||||
|
**Scenario:** Member hasn't paid for 2 years
|
||||||
|
|
||||||
|
**Display:**
|
||||||
|
|
||||||
|
```
|
||||||
|
┌───────────────┬──────────┬────────┬──────────┬─────────┐
|
||||||
|
│ 2023 │ Yearly │ 50 € │ ☐ Open │ [✓] │
|
||||||
|
│ 2024 │ Yearly │ 60 € │ ☐ Open │ [✓] │
|
||||||
|
│ 2025 │ Yearly │ 60 € │ ☐ Open │ [ ] │
|
||||||
|
└───────────────┴──────────┴────────┴──────────┴─────────┘
|
||||||
|
|
||||||
|
[Mark selected as paid/unpaid/suspended] (2 selected)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Amount Changes
|
||||||
|
|
||||||
|
**Scenario:**
|
||||||
|
|
||||||
|
```
|
||||||
|
2023: Regular = 50 €
|
||||||
|
2024: Regular = 60 € (increase)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Result:**
|
||||||
|
|
||||||
|
- Cycle 2023: Saved with 50 € (history)
|
||||||
|
- Cycle 2024: Generated with 60 € (current)
|
||||||
|
- Both cycles show correct historical amount
|
||||||
|
|
||||||
|
### 5. Date Boundaries
|
||||||
|
|
||||||
|
**Problem:** What if today = 01.01.2025?
|
||||||
|
|
||||||
|
**Solution:**
|
||||||
|
|
||||||
|
- Current cycle (2025) is generated
|
||||||
|
- Status: unpaid (open)
|
||||||
|
- Shown in overview
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation Scope
|
||||||
|
|
||||||
|
### MVP (Phase 1)
|
||||||
|
|
||||||
|
**Included:**
|
||||||
|
|
||||||
|
- ✓ Membership fee types (CRUD)
|
||||||
|
- ✓ Automatic cycle generation
|
||||||
|
- ✓ Status management (paid/unpaid/suspended)
|
||||||
|
- ✓ Member overview with membership fee status
|
||||||
|
- ✓ Cycle view per member
|
||||||
|
- ✓ Quick checkbox marking
|
||||||
|
- ✓ Bulk actions
|
||||||
|
- ✓ Amount history
|
||||||
|
- ✓ Same-interval type change
|
||||||
|
- ✓ Default membership fee type
|
||||||
|
- ✓ Joining cycle configuration
|
||||||
|
|
||||||
|
**NOT Included:**
|
||||||
|
|
||||||
|
- ✗ Interval change (only same interval)
|
||||||
|
- ✗ Payment details (date, method)
|
||||||
|
- ✗ Automatic integration (vereinfacht.digital)
|
||||||
|
- ✗ Prorata calculation
|
||||||
|
- ✗ Reports/statistics
|
||||||
|
- ✗ Reminders/dunning (manual via filters)
|
||||||
|
|
||||||
|
### Future Enhancements
|
||||||
|
|
||||||
|
**Phase 2:**
|
||||||
|
|
||||||
|
- Payment details (date, amount, method)
|
||||||
|
- Interval change for future unpaid cycles
|
||||||
|
- Manual vereinfacht.digital links per member
|
||||||
|
- Extended filter options
|
||||||
|
|
||||||
|
**Phase 3:**
|
||||||
|
|
||||||
|
- Automated vereinfacht.digital integration
|
||||||
|
- Automatic payment matching
|
||||||
|
- SEPA integration
|
||||||
|
- Advanced reports
|
||||||
|
|
@ -54,6 +54,9 @@ defmodule Mv.Accounts.User do
|
||||||
auth_method :client_secret_jwt
|
auth_method :client_secret_jwt
|
||||||
code_verifier true
|
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
|
# id_token_signed_response_alg "EdDSA" #-> https://git.local-it.org/local-it/mitgliederverwaltung/issues/87
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -185,7 +188,9 @@ defmodule Mv.Accounts.User do
|
||||||
oidc_user_info = Ash.Changeset.get_argument(changeset, :oidc_user_info)
|
oidc_user_info = Ash.Changeset.get_argument(changeset, :oidc_user_info)
|
||||||
|
|
||||||
# Get the new email from 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
|
changeset
|
||||||
|> Ash.Changeset.change_attribute(:oidc_id, oidc_id)
|
|> Ash.Changeset.change_attribute(:oidc_id, oidc_id)
|
||||||
|
|
@ -239,8 +244,11 @@ defmodule Mv.Accounts.User do
|
||||||
change fn changeset, _ctx ->
|
change fn changeset, _ctx ->
|
||||||
user_info = Ash.Changeset.get_argument(changeset, :user_info)
|
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
|
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"])
|
|> Ash.Changeset.change_attribute(:oidc_id, user_info["sub"] || user_info["id"])
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,9 @@ defmodule Mv.Membership.Member do
|
||||||
|
|
||||||
## Full-Text Search
|
## Full-Text Search
|
||||||
Members have a `search_vector` attribute (tsvector) that is automatically
|
Members have a `search_vector` attribute (tsvector) that is automatically
|
||||||
updated via database trigger. Search includes name, email, notes, and contact fields.
|
updated via database trigger. Search includes name, email, notes, contact fields,
|
||||||
|
and all custom field values. Custom field values are automatically included in
|
||||||
|
the search vector with weight 'C' (same as phone_number, city, etc.).
|
||||||
"""
|
"""
|
||||||
use Ash.Resource,
|
use Ash.Resource,
|
||||||
domain: Mv.Membership,
|
domain: Mv.Membership,
|
||||||
|
|
@ -40,6 +42,21 @@ defmodule Mv.Membership.Member do
|
||||||
|
|
||||||
# Module constants
|
# Module constants
|
||||||
@member_search_limit 10
|
@member_search_limit 10
|
||||||
|
|
||||||
|
# Similarity threshold for fuzzy name/address matching.
|
||||||
|
# Lower value = more results but less accurate (0.1-0.9)
|
||||||
|
#
|
||||||
|
# Fuzzy matching uses two complementary strategies:
|
||||||
|
# 1. % operator: Fast GIN-index-based matching using server-wide threshold (default 0.3)
|
||||||
|
# - Catches exact trigram matches quickly via index
|
||||||
|
# 2. similarity/word_similarity functions: Precise matching with this configurable threshold
|
||||||
|
# - Catches partial matches that % operator might miss
|
||||||
|
#
|
||||||
|
# Value 0.2 chosen based on testing with typical German names:
|
||||||
|
# - "Müller" vs "Mueller": similarity ~0.65 ✓
|
||||||
|
# - "Schmidt" vs "Schmitt": similarity ~0.75 ✓
|
||||||
|
# - "Wagner" vs "Wegner": similarity ~0.55 ✓
|
||||||
|
# - Random unrelated names: similarity ~0.15 ✗
|
||||||
@default_similarity_threshold 0.2
|
@default_similarity_threshold 0.2
|
||||||
|
|
||||||
# Use constants from Mv.Constants for member fields
|
# Use constants from Mv.Constants for member fields
|
||||||
|
|
@ -139,30 +156,21 @@ defmodule Mv.Membership.Member do
|
||||||
|
|
||||||
if is_binary(q) and String.trim(q) != "" do
|
if is_binary(q) and String.trim(q) != "" do
|
||||||
q2 = String.trim(q)
|
q2 = String.trim(q)
|
||||||
pat = "%" <> q2 <> "%"
|
# Sanitize for LIKE patterns (escape % and _), limit length to 100 chars
|
||||||
|
q2_sanitized = sanitize_search_query(q2)
|
||||||
|
pat = "%" <> q2_sanitized <> "%"
|
||||||
|
|
||||||
|
# Build search filters grouped by search type for maintainability
|
||||||
|
# Priority: FTS > Substring > Custom Fields > Fuzzy Matching
|
||||||
|
# Note: FTS and fuzzy use q2 (unsanitized), LIKE-based filters use pat (sanitized)
|
||||||
|
fts_match = build_fts_filter(q2)
|
||||||
|
substring_match = build_substring_filter(q2_sanitized, pat)
|
||||||
|
custom_field_match = build_custom_field_filter(pat)
|
||||||
|
fuzzy_match = build_fuzzy_filter(q2, threshold)
|
||||||
|
|
||||||
# FTS as main filter and fuzzy search just for first name, last name and strees
|
|
||||||
query
|
query
|
||||||
|> Ash.Query.filter(
|
|> Ash.Query.filter(
|
||||||
expr(
|
expr(^fts_match or ^substring_match or ^custom_field_match or ^fuzzy_match)
|
||||||
# Substring on numeric-like fields (best effort, supports middle substrings)
|
|
||||||
fragment("search_vector @@ websearch_to_tsquery('simple', ?)", ^q2) or
|
|
||||||
fragment("search_vector @@ plainto_tsquery('simple', ?)", ^q2) or
|
|
||||||
contains(postal_code, ^q2) or
|
|
||||||
contains(house_number, ^q2) or
|
|
||||||
contains(phone_number, ^q2) or
|
|
||||||
contains(email, ^q2) or
|
|
||||||
contains(city, ^q2) or ilike(city, ^pat) or
|
|
||||||
fragment("? % first_name", ^q2) or
|
|
||||||
fragment("? % last_name", ^q2) or
|
|
||||||
fragment("? % street", ^q2) or
|
|
||||||
fragment("word_similarity(?, first_name) > ?", ^q2, ^threshold) or
|
|
||||||
fragment("word_similarity(?, last_name) > ?", ^q2, ^threshold) or
|
|
||||||
fragment("word_similarity(?, street) > ?", ^q2, ^threshold) or
|
|
||||||
fragment("similarity(first_name, ?) > ?", ^q2, ^threshold) or
|
|
||||||
fragment("similarity(last_name, ?) > ?", ^q2, ^threshold) or
|
|
||||||
fragment("similarity(street, ?) > ?", ^q2, ^threshold)
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
else
|
else
|
||||||
query
|
query
|
||||||
|
|
@ -401,6 +409,70 @@ defmodule Mv.Membership.Member do
|
||||||
identity :unique_email, [:email]
|
identity :unique_email, [:email]
|
||||||
end
|
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 """
|
@doc """
|
||||||
Performs fuzzy search on members using PostgreSQL trigram similarity.
|
Performs fuzzy search on members using PostgreSQL trigram similarity.
|
||||||
|
|
||||||
|
|
@ -412,7 +484,6 @@ defmodule Mv.Membership.Member do
|
||||||
- `query` - Ash.Query.t() to apply search to
|
- `query` - Ash.Query.t() to apply search to
|
||||||
- `opts` - Keyword list or map with search options:
|
- `opts` - Keyword list or map with search options:
|
||||||
- `:query` or `"query"` - Search string
|
- `:query` or `"query"` - Search string
|
||||||
- `:fields` or `"fields"` - Optional field restrictions
|
|
||||||
|
|
||||||
## Returns
|
## Returns
|
||||||
- Modified Ash.Query.t() with search filters applied
|
- Modified Ash.Query.t() with search filters applied
|
||||||
|
|
@ -433,16 +504,103 @@ defmodule Mv.Membership.Member do
|
||||||
if String.trim(q) == "" do
|
if String.trim(q) == "" do
|
||||||
query
|
query
|
||||||
else
|
else
|
||||||
args =
|
Ash.Query.for_read(query, :search, %{query: q})
|
||||||
case opts[:fields] || opts["fields"] do
|
|
||||||
nil -> %{query: q}
|
|
||||||
fields -> %{query: q, fields: fields}
|
|
||||||
end
|
|
||||||
|
|
||||||
Ash.Query.for_read(query, :search, args)
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Search Input Sanitization
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
# Sanitizes search input to prevent LIKE pattern injection.
|
||||||
|
# Escapes SQL LIKE wildcards (% and _) and limits query length.
|
||||||
|
#
|
||||||
|
# ## Examples
|
||||||
|
#
|
||||||
|
# iex> sanitize_search_query("test%injection")
|
||||||
|
# "test\\%injection"
|
||||||
|
#
|
||||||
|
# iex> sanitize_search_query("very_long_search")
|
||||||
|
# "very\\_long\\_search"
|
||||||
|
#
|
||||||
|
defp sanitize_search_query(query) when is_binary(query) do
|
||||||
|
query
|
||||||
|
|> String.slice(0, 100)
|
||||||
|
|> String.replace("\\", "\\\\")
|
||||||
|
|> String.replace("%", "\\%")
|
||||||
|
|> String.replace("_", "\\_")
|
||||||
|
end
|
||||||
|
|
||||||
|
defp sanitize_search_query(_), do: ""
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# Search Filter Builders
|
||||||
|
# ============================================================================
|
||||||
|
# These functions build search filters grouped by search type for maintainability.
|
||||||
|
# Priority order: FTS > Substring > Custom Fields > Fuzzy Matching
|
||||||
|
|
||||||
|
# Builds full-text search filter using tsvector (highest priority, fastest)
|
||||||
|
# Uses GIN index on search_vector for optimal performance
|
||||||
|
defp build_fts_filter(query) do
|
||||||
|
expr(
|
||||||
|
fragment("search_vector @@ websearch_to_tsquery('simple', ?)", ^query) or
|
||||||
|
fragment("search_vector @@ plainto_tsquery('simple', ?)", ^query)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Builds substring search filter for structured fields
|
||||||
|
# Note: contains/2 uses ILIKE '%value%' which is not index-optimized
|
||||||
|
# Performance: Good for small datasets, may be slow on large tables
|
||||||
|
defp build_substring_filter(query, _pattern) do
|
||||||
|
expr(
|
||||||
|
contains(postal_code, ^query) or
|
||||||
|
contains(house_number, ^query) or
|
||||||
|
contains(phone_number, ^query) or
|
||||||
|
contains(email, ^query) or
|
||||||
|
contains(city, ^query)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Builds search filter for custom field values using ILIKE on JSONB
|
||||||
|
# Note: ILIKE on JSONB is not index-optimized, may be slow with many custom fields
|
||||||
|
# This is a fallback for substring matching in custom fields (e.g., phone numbers)
|
||||||
|
# Uses ->> operator which always returns TEXT directly (no need for -> + ::text fallback)
|
||||||
|
# Important: `id` must be passed as parameter to correctly reference the outer members table
|
||||||
|
defp build_custom_field_filter(pattern) do
|
||||||
|
expr(
|
||||||
|
fragment(
|
||||||
|
"EXISTS (SELECT 1 FROM custom_field_values WHERE member_id = ? AND (value->>'_union_value' ILIKE ? OR value->>'value' ILIKE ?))",
|
||||||
|
id,
|
||||||
|
^pattern,
|
||||||
|
^pattern
|
||||||
|
)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Builds fuzzy/trigram matching filter for name and street fields.
|
||||||
|
# Uses pg_trgm extension with GIN indexes for performance.
|
||||||
|
#
|
||||||
|
# Two-tier matching strategy:
|
||||||
|
# - % operator: Uses server-wide pg_trgm.similarity_threshold (typically 0.3)
|
||||||
|
# for fast index-based initial filtering
|
||||||
|
# - similarity/word_similarity: Uses @default_similarity_threshold (0.2)
|
||||||
|
# for more lenient matching to catch edge cases
|
||||||
|
#
|
||||||
|
# Note: Requires trigram GIN indexes on first_name, last_name, street.
|
||||||
|
defp build_fuzzy_filter(query, threshold) do
|
||||||
|
expr(
|
||||||
|
fragment("? % first_name", ^query) or
|
||||||
|
fragment("? % last_name", ^query) or
|
||||||
|
fragment("? % street", ^query) or
|
||||||
|
fragment("word_similarity(?, first_name) > ?", ^query, ^threshold) or
|
||||||
|
fragment("word_similarity(?, last_name) > ?", ^query, ^threshold) or
|
||||||
|
fragment("word_similarity(?, street) > ?", ^query, ^threshold) or
|
||||||
|
fragment("similarity(first_name, ?) > ?", ^query, ^threshold) or
|
||||||
|
fragment("similarity(last_name, ?) > ?", ^query, ^threshold) or
|
||||||
|
fragment("similarity(street, ?) > ?", ^query, ^threshold)
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
# Private helper to apply filters for :available_for_linking action
|
# Private helper to apply filters for :available_for_linking action
|
||||||
# user_email: may be nil/empty when creating new user, or populated when editing
|
# user_email: may be nil/empty when creating new user, or populated when editing
|
||||||
# search_query: optional search term for fuzzy matching
|
# search_query: optional search term for fuzzy matching
|
||||||
|
|
@ -451,9 +609,9 @@ defmodule Mv.Membership.Member do
|
||||||
# - Empty user_email ("") → email == "" is always false → only fuzzy search matches
|
# - Empty user_email ("") → email == "" is always false → only fuzzy search matches
|
||||||
# - This allows a single filter expression instead of duplicating fuzzy search logic
|
# - This allows a single filter expression instead of duplicating fuzzy search logic
|
||||||
#
|
#
|
||||||
# Cyclomatic complexity is unavoidable here: PostgreSQL fuzzy search requires
|
# Note: Custom field search is intentionally excluded from linking to optimize
|
||||||
# multiple OR conditions for good search quality (FTS + trigram similarity + substring)
|
# autocomplete performance. Custom fields are still searchable via the main
|
||||||
# credo:disable-for-next-line Credo.Check.Refactor.CyclomaticComplexity
|
# member search which uses the indexed search_vector.
|
||||||
defp apply_linking_filters(query, user_email, search_query) do
|
defp apply_linking_filters(query, user_email, search_query) do
|
||||||
has_search = search_query && String.trim(search_query) != ""
|
has_search = search_query && String.trim(search_query) != ""
|
||||||
# Use empty string instead of nil to simplify filter logic
|
# Use empty string instead of nil to simplify filter logic
|
||||||
|
|
@ -462,35 +620,23 @@ defmodule Mv.Membership.Member do
|
||||||
if has_search do
|
if has_search do
|
||||||
# Search query provided: return email-match OR fuzzy-search candidates
|
# Search query provided: return email-match OR fuzzy-search candidates
|
||||||
trimmed_search = String.trim(search_query)
|
trimmed_search = String.trim(search_query)
|
||||||
|
# Sanitize for LIKE patterns (contains uses ILIKE internally)
|
||||||
|
sanitized_search = sanitize_search_query(trimmed_search)
|
||||||
|
|
||||||
|
# Build search filters - excluding custom_field_filter for performance
|
||||||
|
fts_match = build_fts_filter(trimmed_search)
|
||||||
|
fuzzy_match = build_fuzzy_filter(trimmed_search, @default_similarity_threshold)
|
||||||
|
email_substring_match = expr(contains(email, ^sanitized_search))
|
||||||
|
|
||||||
query
|
query
|
||||||
|> Ash.Query.filter(
|
|> Ash.Query.filter(
|
||||||
expr(
|
expr(
|
||||||
# Email match candidate (for filter_by_email_match priority)
|
# Email exact match has highest priority (for filter_by_email_match)
|
||||||
# If email is "", this is always false and fuzzy search takes over
|
# If email is "", this is always false and search filters take over
|
||||||
# Fuzzy search candidates
|
|
||||||
email == ^trimmed_email or
|
email == ^trimmed_email or
|
||||||
fragment("search_vector @@ websearch_to_tsquery('simple', ?)", ^trimmed_search) or
|
^fts_match or
|
||||||
fragment("search_vector @@ plainto_tsquery('simple', ?)", ^trimmed_search) or
|
^fuzzy_match or
|
||||||
fragment("? % first_name", ^trimmed_search) or
|
^email_substring_match
|
||||||
fragment("? % last_name", ^trimmed_search) or
|
|
||||||
fragment("word_similarity(?, first_name) > 0.2", ^trimmed_search) or
|
|
||||||
fragment(
|
|
||||||
"word_similarity(?, last_name) > ?",
|
|
||||||
^trimmed_search,
|
|
||||||
^@default_similarity_threshold
|
|
||||||
) or
|
|
||||||
fragment(
|
|
||||||
"similarity(first_name, ?) > ?",
|
|
||||||
^trimmed_search,
|
|
||||||
^@default_similarity_threshold
|
|
||||||
) or
|
|
||||||
fragment(
|
|
||||||
"similarity(last_name, ?) > ?",
|
|
||||||
^trimmed_search,
|
|
||||||
^@default_similarity_threshold
|
|
||||||
) or
|
|
||||||
contains(email, ^trimmed_search)
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
else
|
else
|
||||||
|
|
|
||||||
|
|
@ -18,5 +18,17 @@ defmodule Mv.Constants do
|
||||||
:postal_code
|
:postal_code
|
||||||
]
|
]
|
||||||
|
|
||||||
|
@custom_field_prefix "custom_field_"
|
||||||
|
|
||||||
def member_fields, do: @member_fields
|
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
|
end
|
||||||
|
|
|
||||||
|
|
@ -79,7 +79,7 @@ defmodule MvWeb.CoreComponents do
|
||||||
<p>{msg}</p>
|
<p>{msg}</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-1" />
|
<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" />
|
<.icon name="hero-x-mark" class="size-5 opacity-40 group-hover:opacity-70" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -119,6 +119,147 @@ defmodule MvWeb.CoreComponents do
|
||||||
end
|
end
|
||||||
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"
|
||||||
|
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 a section in with a border similar to cards.
|
||||||
|
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
<.form_section title={gettext("Personal Data")}>
|
||||||
|
<p>input</p>
|
||||||
|
</form_section>
|
||||||
|
"""
|
||||||
|
attr :title, :string, required: true
|
||||||
|
slot :inner_block, required: true
|
||||||
|
|
||||||
|
def 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
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Renders an input with label and error messages.
|
Renders an input with label and error messages.
|
||||||
|
|
||||||
|
|
@ -317,7 +458,7 @@ defmodule MvWeb.CoreComponents do
|
||||||
~H"""
|
~H"""
|
||||||
<header class={[@actions != [] && "flex items-center justify-between gap-6", "pb-4", @class]}>
|
<header class={[@actions != [] && "flex items-center justify-between gap-6", "pb-4", @class]}>
|
||||||
<div>
|
<div>
|
||||||
<h1 class="text-lg font-semibold leading-8">
|
<h1 class="text-xl font-semibold leading-8">
|
||||||
{render_slot(@inner_block)}
|
{render_slot(@inner_block)}
|
||||||
</h1>
|
</h1>
|
||||||
<p :if={@subtitle != []} class="text-sm text-base-content/70">
|
<p :if={@subtitle != []} class="text-sm text-base-content/70">
|
||||||
|
|
@ -357,6 +498,8 @@ defmodule MvWeb.CoreComponents do
|
||||||
|
|
||||||
slot :col, required: true do
|
slot :col, required: true do
|
||||||
attr :label, :string
|
attr :label, :string
|
||||||
|
attr :class, :string
|
||||||
|
attr :col_click, :any, doc: "optional column-specific click handler that overrides row_click"
|
||||||
end
|
end
|
||||||
|
|
||||||
slot :action, doc: "the slot for showing user actions in the last table column"
|
slot :action, doc: "the slot for showing user actions in the last table column"
|
||||||
|
|
@ -368,61 +511,93 @@ defmodule MvWeb.CoreComponents do
|
||||||
end
|
end
|
||||||
|
|
||||||
~H"""
|
~H"""
|
||||||
<table class="table table-zebra">
|
<div class="overflow-auto">
|
||||||
<thead>
|
<table class="table table-zebra">
|
||||||
<tr>
|
<thead>
|
||||||
<th :for={col <- @col}>{col[:label]}</th>
|
<tr>
|
||||||
<th :for={dyn_col <- @dynamic_cols}>
|
<th :for={col <- @col} class={Map.get(col, :class)}>{col[:label]}</th>
|
||||||
<.live_component
|
<th :for={dyn_col <- @dynamic_cols}>
|
||||||
module={MvWeb.Components.SortHeaderComponent}
|
<.live_component
|
||||||
id={:"sort_custom_field_#{dyn_col[:custom_field].id}"}
|
module={MvWeb.Components.SortHeaderComponent}
|
||||||
field={"custom_field_#{dyn_col[:custom_field].id}"}
|
id={:"sort_custom_field_#{dyn_col[:custom_field].id}"}
|
||||||
label={dyn_col[:custom_field].name}
|
field={"custom_field_#{dyn_col[:custom_field].id}"}
|
||||||
sort_field={@sort_field}
|
label={dyn_col[:custom_field].name}
|
||||||
sort_order={@sort_order}
|
sort_field={@sort_field}
|
||||||
/>
|
sort_order={@sort_order}
|
||||||
</th>
|
/>
|
||||||
<th :if={@action != []}>
|
</th>
|
||||||
<span class="sr-only">{gettext("Actions")}</span>
|
<th :if={@action != []}>
|
||||||
</th>
|
<span class="sr-only">{gettext("Actions")}</span>
|
||||||
</tr>
|
</th>
|
||||||
</thead>
|
</tr>
|
||||||
<tbody id={@id} phx-update={is_struct(@rows, Phoenix.LiveView.LiveStream) && "stream"}>
|
</thead>
|
||||||
<tr :for={row <- @rows} id={@row_id && @row_id.(row)}>
|
<tbody id={@id} phx-update={is_struct(@rows, Phoenix.LiveView.LiveStream) && "stream"}>
|
||||||
<td
|
<tr :for={row <- @rows} id={@row_id && @row_id.(row)}>
|
||||||
:for={col <- @col}
|
<td
|
||||||
phx-click={@row_click && @row_click.(row)}
|
:for={col <- @col}
|
||||||
class={@row_click && "hover:cursor-pointer"}
|
phx-click={
|
||||||
>
|
(col[:col_click] && col[:col_click].(@row_item.(row))) ||
|
||||||
{render_slot(col, @row_item.(row))}
|
(@row_click && @row_click.(row))
|
||||||
</td>
|
}
|
||||||
<td
|
class={
|
||||||
:for={dyn_col <- @dynamic_cols}
|
col_class = Map.get(col, :class)
|
||||||
phx-click={@row_click && @row_click.(row)}
|
has_click = col[:col_click] || @row_click
|
||||||
class={@row_click && "hover:cursor-pointer"}
|
classes = ["max-w-xs"]
|
||||||
>
|
|
||||||
{if dyn_col[:render] do
|
|
||||||
rendered = dyn_col[:render].(@row_item.(row))
|
|
||||||
|
|
||||||
if rendered == "" do
|
classes =
|
||||||
""
|
if col_class == nil || (col_class && !String.contains?(col_class, "text-center")) do
|
||||||
|
["truncate" | classes]
|
||||||
|
else
|
||||||
|
classes
|
||||||
|
end
|
||||||
|
|
||||||
|
classes =
|
||||||
|
if has_click do
|
||||||
|
["hover:cursor-pointer" | classes]
|
||||||
|
else
|
||||||
|
classes
|
||||||
|
end
|
||||||
|
|
||||||
|
classes =
|
||||||
|
if col_class do
|
||||||
|
[col_class | classes]
|
||||||
|
else
|
||||||
|
classes
|
||||||
|
end
|
||||||
|
|
||||||
|
Enum.join(classes, " ")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{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
|
else
|
||||||
rendered
|
""
|
||||||
end
|
end}
|
||||||
else
|
</td>
|
||||||
""
|
<td :if={@action != []} class="w-0 font-semibold">
|
||||||
end}
|
<div class="flex gap-4">
|
||||||
</td>
|
<%= for action <- @action do %>
|
||||||
<td :if={@action != []} class="w-0 font-semibold">
|
{render_slot(action, @row_item.(row))}
|
||||||
<div class="flex gap-4">
|
<% end %>
|
||||||
<%= for action <- @action do %>
|
</div>
|
||||||
{render_slot(action, @row_item.(row))}
|
</td>
|
||||||
<% end %>
|
</tr>
|
||||||
</div>
|
</tbody>
|
||||||
</td>
|
</table>
|
||||||
</tr>
|
</div>
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
"""
|
"""
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -23,8 +23,19 @@ defmodule MvWeb.Layouts.Navbar do
|
||||||
<a class="btn btn-ghost text-xl">{@club_name}</a>
|
<a class="btn btn-ghost text-xl">{@club_name}</a>
|
||||||
<ul class="menu menu-horizontal bg-base-200">
|
<ul class="menu menu-horizontal bg-base-200">
|
||||||
<li><.link navigate="/members">{gettext("Members")}</.link></li>
|
<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><.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>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,11 @@ defmodule MvWeb.Endpoint do
|
||||||
plug Phoenix.Ecto.CheckRepoStatus, otp_app: :mv
|
plug Phoenix.Ecto.CheckRepoStatus, otp_app: :mv
|
||||||
end
|
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,
|
plug Phoenix.LiveDashboard.RequestLogger,
|
||||||
param_key: "request_logger",
|
param_key: "request_logger",
|
||||||
cookie_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,192 @@
|
||||||
|
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) when is_atom(field) do
|
||||||
|
MvWeb.Translations.MemberFields.label(field)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp format_field_label(field) when is_binary(field) do
|
||||||
|
case safe_to_existing_atom(field) do
|
||||||
|
{:ok, atom} -> MvWeb.Translations.MemberFields.label(atom)
|
||||||
|
:error -> fallback_label(field)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp safe_to_existing_atom(string) do
|
||||||
|
{:ok, String.to_existing_atom(string)}
|
||||||
|
rescue
|
||||||
|
ArgumentError -> :error
|
||||||
|
end
|
||||||
|
|
||||||
|
defp fallback_label(field) do
|
||||||
|
field
|
||||||
|
|> 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
|
||||||
|
|
@ -44,7 +44,7 @@ defmodule MvWeb.Components.PaymentFilterComponent do
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class={[
|
class={[
|
||||||
"btn btn-ghost gap-2",
|
"btn gap-2",
|
||||||
@paid_filter && "btn-active"
|
@paid_filter && "btn-active"
|
||||||
]}
|
]}
|
||||||
phx-click="toggle_dropdown"
|
phx-click="toggle_dropdown"
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ defmodule MvWeb.Components.SortHeaderComponent do
|
||||||
@impl true
|
@impl true
|
||||||
def render(assigns) do
|
def render(assigns) do
|
||||||
~H"""
|
~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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label={aria_sort(@field, @sort_field, @sort_order)}
|
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,142 +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)
|
|
||||||
- show_in_overview - If true, this custom field will be displayed in the member overview table (default: true)
|
|
||||||
|
|
||||||
## 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")} />
|
|
||||||
<.input field={@form[:show_in_overview]} type="checkbox" label={gettext("Show in overview")} />
|
|
||||||
|
|
||||||
<.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
|
|
||||||
274
lib/mv_web/live/custom_field_live/index_component.ex
Normal file
274
lib/mv_web/live/custom_field_live/index_component.ex
Normal file
|
|
@ -0,0 +1,274 @@
|
||||||
|
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
|
||||||
|
assigns = assign(assigns, :field_type_label, &MvWeb.Translations.FieldTypes.label/1)
|
||||||
|
|
||||||
|
~H"""
|
||||||
|
<div id={@id}>
|
||||||
|
<.form_section title={gettext("Custom Fields")}>
|
||||||
|
<div class="flex">
|
||||||
|
<p class="text-sm text-base-content/70">
|
||||||
|
{gettext("These will appear in addition to other data when adding new members.")}
|
||||||
|
</p>
|
||||||
|
<div class="ml-auto">
|
||||||
|
<.button
|
||||||
|
class="ml-auto"
|
||||||
|
variant="primary"
|
||||||
|
phx-click="new_custom_field"
|
||||||
|
phx-target={@myself}
|
||||||
|
>
|
||||||
|
<.icon name="hero-plus" /> {gettext("New Custom field")}
|
||||||
|
</.button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<%!-- 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")}>
|
||||||
|
{@field_type_label.(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")}
|
||||||
|
class="max-w-[9.375rem] text-center"
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
</.form_section>
|
||||||
|
</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
|
|
||||||
|
|
@ -4,6 +4,7 @@ defmodule MvWeb.GlobalSettingsLive do
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
- Edit the association/club name
|
- Edit the association/club name
|
||||||
|
- Manage custom fields
|
||||||
- Real-time form validation
|
- Real-time form validation
|
||||||
- Success/error feedback
|
- Success/error feedback
|
||||||
|
|
||||||
|
|
@ -28,7 +29,7 @@ defmodule MvWeb.GlobalSettingsLive do
|
||||||
|
|
||||||
{:ok,
|
{:ok,
|
||||||
socket
|
socket
|
||||||
|> assign(:page_title, gettext("Club Settings"))
|
|> assign(:page_title, gettext("Settings"))
|
||||||
|> assign(:settings, settings)
|
|> assign(:settings, settings)
|
||||||
|> assign_form()}
|
|> assign_form()}
|
||||||
end
|
end
|
||||||
|
|
@ -38,24 +39,34 @@ defmodule MvWeb.GlobalSettingsLive do
|
||||||
~H"""
|
~H"""
|
||||||
<Layouts.app flash={@flash} current_user={@current_user}>
|
<Layouts.app flash={@flash} current_user={@current_user}>
|
||||||
<.header>
|
<.header>
|
||||||
{gettext("Club Settings")}
|
{gettext("Settings")}
|
||||||
<:subtitle>
|
<:subtitle>
|
||||||
{gettext("Manage global settings for the association.")}
|
{gettext("Manage global settings for the association.")}
|
||||||
</:subtitle>
|
</:subtitle>
|
||||||
</.header>
|
</.header>
|
||||||
|
|
||||||
<.form for={@form} id="settings-form" phx-change="validate" phx-submit="save">
|
<%!-- Club Settings Section --%>
|
||||||
<.input
|
<.form_section title={gettext("Club Settings")}>
|
||||||
field={@form[:club_name]}
|
<.form for={@form} id="settings-form" phx-change="validate" phx-submit="save">
|
||||||
type="text"
|
<div class="w-100">
|
||||||
label={gettext("Association Name")}
|
<.input
|
||||||
required
|
field={@form[:club_name]}
|
||||||
/>
|
type="text"
|
||||||
|
label={gettext("Association Name")}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<.button phx-disable-with={gettext("Saving...")} variant="primary">
|
<.button phx-disable-with={gettext("Saving...")} variant="primary">
|
||||||
{gettext("Save Settings")}
|
{gettext("Save Settings")}
|
||||||
</.button>
|
</.button>
|
||||||
</.form>
|
</.form>
|
||||||
|
</.form_section>
|
||||||
|
<%!-- Custom Fields Section --%>
|
||||||
|
<.live_component
|
||||||
|
module={MvWeb.CustomFieldLive.IndexComponent}
|
||||||
|
id="custom-fields-component"
|
||||||
|
/>
|
||||||
</Layouts.app>
|
</Layouts.app>
|
||||||
"""
|
"""
|
||||||
end
|
end
|
||||||
|
|
@ -66,6 +77,7 @@ defmodule MvWeb.GlobalSettingsLive do
|
||||||
assign(socket, form: AshPhoenix.Form.validate(socket.assigns.form, setting_params))}
|
assign(socket, form: AshPhoenix.Form.validate(socket.assigns.form, setting_params))}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
def handle_event("save", %{"setting" => setting_params}, socket) do
|
def handle_event("save", %{"setting" => setting_params}, socket) do
|
||||||
case AshPhoenix.Form.submit(socket.assigns.form, params: setting_params) do
|
case AshPhoenix.Form.submit(socket.assigns.form, params: setting_params) do
|
||||||
{:ok, updated_settings} ->
|
{:ok, updated_settings} ->
|
||||||
|
|
@ -82,6 +94,37 @@ defmodule MvWeb.GlobalSettingsLive do
|
||||||
end
|
end
|
||||||
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
|
defp assign_form(%{assigns: %{settings: settings}} = socket) do
|
||||||
form =
|
form =
|
||||||
AshPhoenix.Form.for_update(
|
AshPhoenix.Form.for_update(
|
||||||
|
|
|
||||||
|
|
@ -5,80 +5,212 @@ defmodule MvWeb.MemberLive.Form do
|
||||||
## Features
|
## Features
|
||||||
- Create new members with personal information
|
- Create new members with personal information
|
||||||
- Edit existing member details
|
- 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
|
- Real-time validation with visual feedback
|
||||||
- Link/unlink user accounts
|
|
||||||
|
|
||||||
## Form Fields
|
## Form Sections
|
||||||
**Required:**
|
- Personal Data: Name, address, contact information, membership dates, notes
|
||||||
- first_name, last_name, email
|
- Custom Fields: Dynamic fields in uniform grid layout (displayed sorted by name)
|
||||||
|
- Payment Data: Mockup section (not editable)
|
||||||
**Optional:**
|
|
||||||
- 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.
|
|
||||||
|
|
||||||
## Events
|
## Events
|
||||||
- `validate` - Real-time form validation
|
- `validate` - Real-time form validation
|
||||||
- `save` - Submit form (create or update member)
|
- `save` - Submit form (create or update member)
|
||||||
- Custom field value management events for adding/removing custom fields
|
|
||||||
"""
|
"""
|
||||||
use MvWeb, :live_view
|
use MvWeb, :live_view
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def render(assigns) do
|
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"""
|
~H"""
|
||||||
<Layouts.app flash={@flash} current_user={@current_user}>
|
<Layouts.app flash={@flash} current_user={@current_user}>
|
||||||
<.header>
|
|
||||||
{@page_title}
|
|
||||||
<:subtitle>
|
|
||||||
{gettext("Fields marked with an asterisk (*) cannot be empty.")}
|
|
||||||
</:subtitle>
|
|
||||||
</.header>
|
|
||||||
|
|
||||||
<.form for={@form} id="member-form" phx-change="validate" phx-submit="save">
|
<.form for={@form} id="member-form" phx-change="validate" phx-submit="save">
|
||||||
<.input field={@form[:first_name]} label={gettext("First Name")} required />
|
<%!-- Header with Back button, Name display, and Save button --%>
|
||||||
<.input field={@form[:last_name]} label={gettext("Last Name")} required />
|
<div class="flex items-center justify-between gap-4 pb-4">
|
||||||
<.input field={@form[:email]} label={gettext("Email")} required type="email" />
|
<.button navigate={return_path(@return_to, @member)} type="button">
|
||||||
<.input field={@form[:paid]} label={gettext("Paid")} type="checkbox" />
|
<.icon name="hero-arrow-left" class="size-4" />
|
||||||
<.input field={@form[:phone_number]} label={gettext("Phone Number")} />
|
{gettext("Back")}
|
||||||
<.input field={@form[:join_date]} label={gettext("Join Date")} type="date" />
|
</.button>
|
||||||
<.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")} />
|
|
||||||
|
|
||||||
<h3 class="mt-8 mb-2 text-lg font-semibold">{gettext("Custom Field Values")}</h3>
|
<h1 class="text-2xl font-bold text-center flex-1">
|
||||||
<.inputs_for :let={f_custom_field_value} field={@form[:custom_field_values]}>
|
<%= if @member do %>
|
||||||
<% type =
|
{@member.first_name} {@member.last_name}
|
||||||
Enum.find(@custom_fields, &(&1.id == f_custom_field_value[:custom_field_id].value)) %>
|
<% else %>
|
||||||
<.inputs_for :let={value_form} field={f_custom_field_value[:value]}>
|
{gettext("New Member")}
|
||||||
<% input_type =
|
<% end %>
|
||||||
cond do
|
</h1>
|
||||||
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>
|
|
||||||
|
|
||||||
<.button phx-disable-with={gettext("Saving...")} variant="primary">
|
<.button phx-disable-with={gettext("Saving...")} variant="primary" type="submit">
|
||||||
{gettext("Save Member")}
|
{gettext("Save")}
|
||||||
</.button>
|
</.button>
|
||||||
<.button navigate={return_path(@return_to, @member)}>{gettext("Cancel")}</.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>
|
</.form>
|
||||||
</Layouts.app>
|
</Layouts.app>
|
||||||
"""
|
"""
|
||||||
|
|
@ -106,8 +238,8 @@ defmodule MvWeb.MemberLive.Form do
|
||||||
id -> Ash.get!(Mv.Membership.Member, id)
|
id -> Ash.get!(Mv.Membership.Member, id)
|
||||||
end
|
end
|
||||||
|
|
||||||
action = if is_nil(member), do: "New", else: "Edit"
|
page_title =
|
||||||
page_title = action <> " " <> "Member"
|
if is_nil(member), do: gettext("Create Member"), else: gettext("Edit Member")
|
||||||
|
|
||||||
{:ok,
|
{:ok,
|
||||||
socket
|
socket
|
||||||
|
|
@ -213,5 +345,18 @@ defmodule MvWeb.MemberLive.Form do
|
||||||
end
|
end
|
||||||
|
|
||||||
defp return_path("index", _member), do: ~p"/members"
|
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}"
|
defp return_path("show", member), do: ~p"/members/#{member.id}"
|
||||||
|
|
||||||
|
# -----------------------------------------------------------------
|
||||||
|
# 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
|
end
|
||||||
|
|
|
||||||
|
|
@ -32,9 +32,12 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
|
|
||||||
alias Mv.Membership
|
alias Mv.Membership
|
||||||
alias MvWeb.MemberLive.Index.Formatter
|
alias MvWeb.MemberLive.Index.Formatter
|
||||||
|
alias MvWeb.Helpers.DateFormatter
|
||||||
|
alias MvWeb.MemberLive.Index.FieldSelection
|
||||||
|
alias MvWeb.MemberLive.Index.FieldVisibility
|
||||||
|
|
||||||
# Prefix used in sort field names for custom fields (e.g., "custom_field_<id>")
|
# Prefix used in sort field names for custom fields (e.g., "custom_field_<id>")
|
||||||
@custom_field_prefix "custom_field_"
|
@custom_field_prefix Mv.Constants.custom_field_prefix()
|
||||||
|
|
||||||
# Member fields that are loaded for the overview
|
# Member fields that are loaded for the overview
|
||||||
# Uses constants from Mv.Constants to ensure consistency
|
# Uses constants from Mv.Constants to ensure consistency
|
||||||
|
|
@ -49,8 +52,8 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
payment filter, and member selection. Actual data loading happens in `handle_params/3`.
|
payment filter, and member selection. Actual data loading happens in `handle_params/3`.
|
||||||
"""
|
"""
|
||||||
@impl true
|
@impl true
|
||||||
def mount(_params, _session, socket) do
|
def mount(_params, session, socket) do
|
||||||
# Load custom fields that should be shown in overview
|
# Load custom fields that should be shown in overview (for display)
|
||||||
# Note: Using Ash.read! (bang version) - errors will be handled by Phoenix LiveView
|
# Note: Using Ash.read! (bang version) - errors will be handled by Phoenix LiveView
|
||||||
# and result in a 500 error page. This is appropriate for LiveViews where errors
|
# and result in a 500 error page. This is appropriate for LiveViews where errors
|
||||||
# should be visible to the user rather than silently failing.
|
# should be visible to the user rather than silently failing.
|
||||||
|
|
@ -60,6 +63,12 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
|> Ash.Query.sort(name: :asc)
|
|> Ash.Query.sort(name: :asc)
|
||||||
|> Ash.read!()
|
|> Ash.read!()
|
||||||
|
|
||||||
|
# Load ALL custom fields for the dropdown (to show all available fields)
|
||||||
|
all_custom_fields =
|
||||||
|
Mv.Membership.CustomField
|
||||||
|
|> Ash.Query.sort(name: :asc)
|
||||||
|
|> Ash.read!()
|
||||||
|
|
||||||
# Load settings once to avoid N+1 queries
|
# Load settings once to avoid N+1 queries
|
||||||
settings =
|
settings =
|
||||||
case Membership.get_settings() do
|
case Membership.get_settings() do
|
||||||
|
|
@ -68,6 +77,20 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
{:error, _} -> %{member_field_visibility: %{}}
|
{:error, _} -> %{member_field_visibility: %{}}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Load user field selection from session
|
||||||
|
session_selection = FieldSelection.get_from_session(session)
|
||||||
|
|
||||||
|
# Get all available fields (for dropdown - includes ALL custom fields)
|
||||||
|
all_available_fields = FieldVisibility.get_all_available_fields(all_custom_fields)
|
||||||
|
|
||||||
|
# Merge session selection with global settings for initial state (use all_custom_fields)
|
||||||
|
initial_selection =
|
||||||
|
FieldVisibility.merge_with_global_settings(
|
||||||
|
session_selection,
|
||||||
|
settings,
|
||||||
|
all_custom_fields
|
||||||
|
)
|
||||||
|
|
||||||
socket =
|
socket =
|
||||||
socket
|
socket
|
||||||
|> assign(:page_title, gettext("Members"))
|
|> assign(:page_title, gettext("Members"))
|
||||||
|
|
@ -76,8 +99,15 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
|> assign_new(:sort_order, fn -> :asc end)
|
|> assign_new(:sort_order, fn -> :asc end)
|
||||||
|> assign(:paid_filter, nil)
|
|> assign(:paid_filter, nil)
|
||||||
|> assign(:selected_members, MapSet.new())
|
|> assign(:selected_members, MapSet.new())
|
||||||
|
|> assign(:settings, settings)
|
||||||
|> assign(:custom_fields_visible, custom_fields_visible)
|
|> assign(:custom_fields_visible, custom_fields_visible)
|
||||||
|> assign(:member_fields_visible, get_visible_member_fields(settings))
|
|> assign(:all_custom_fields, all_custom_fields)
|
||||||
|
|> assign(:all_available_fields, all_available_fields)
|
||||||
|
|> assign(:user_field_selection, initial_selection)
|
||||||
|
|> assign(
|
||||||
|
:member_fields_visible,
|
||||||
|
FieldVisibility.get_visible_member_fields(initial_selection)
|
||||||
|
)
|
||||||
|
|
||||||
# We call handle params to use the query from the URL
|
# We call handle params to use the query from the URL
|
||||||
{:ok, socket}
|
{:ok, socket}
|
||||||
|
|
@ -182,6 +212,8 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
## Supported messages:
|
## Supported messages:
|
||||||
- `{:sort, field}` - Sort event from SortHeaderComponent. Updates sort field/order and syncs URL
|
- `{:sort, field}` - Sort event from SortHeaderComponent. Updates sort field/order and syncs URL
|
||||||
- `{:search_changed, query}` - Search event from SearchBarComponent. Filters members and syncs URL
|
- `{:search_changed, query}` - Search event from SearchBarComponent. Filters members and syncs URL
|
||||||
|
- `{:field_toggled, field, visible}` - Field toggle event from FieldVisibilityDropdownComponent
|
||||||
|
- `{:fields_selected, selection}` - Select all/deselect all event from FieldVisibilityDropdownComponent
|
||||||
"""
|
"""
|
||||||
@impl true
|
@impl true
|
||||||
def handle_info({:sort, field_str}, socket) do
|
def handle_info({:sort, field_str}, socket) do
|
||||||
|
|
@ -250,24 +282,111 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
)}
|
)}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_info({:field_toggled, field_string, visible}, socket) do
|
||||||
|
# Update user field selection
|
||||||
|
new_selection = Map.put(socket.assigns.user_field_selection, field_string, visible)
|
||||||
|
|
||||||
|
# Save to session (cookie will be saved on next page load via handle_params)
|
||||||
|
socket = update_session_field_selection(socket, new_selection)
|
||||||
|
|
||||||
|
# Merge with global settings (use all_custom_fields to allow enabling globally hidden fields)
|
||||||
|
final_selection =
|
||||||
|
FieldVisibility.merge_with_global_settings(
|
||||||
|
new_selection,
|
||||||
|
socket.assigns.settings,
|
||||||
|
socket.assigns.all_custom_fields
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get visible fields
|
||||||
|
visible_member_fields = FieldVisibility.get_visible_member_fields(final_selection)
|
||||||
|
visible_custom_fields = FieldVisibility.get_visible_custom_fields(final_selection)
|
||||||
|
|
||||||
|
socket =
|
||||||
|
socket
|
||||||
|
|> assign(:user_field_selection, final_selection)
|
||||||
|
|> assign(:member_fields_visible, visible_member_fields)
|
||||||
|
|> assign(:visible_custom_field_ids, extract_custom_field_ids(visible_custom_fields))
|
||||||
|
|> load_members()
|
||||||
|
|> prepare_dynamic_cols()
|
||||||
|
|> push_field_selection_url()
|
||||||
|
|
||||||
|
{:noreply, socket}
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_info({:fields_selected, selection}, socket) do
|
||||||
|
# Save to session
|
||||||
|
socket = update_session_field_selection(socket, selection)
|
||||||
|
|
||||||
|
# Merge with global settings (use all_custom_fields for merging)
|
||||||
|
final_selection =
|
||||||
|
FieldVisibility.merge_with_global_settings(
|
||||||
|
selection,
|
||||||
|
socket.assigns.settings,
|
||||||
|
socket.assigns.all_custom_fields
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get visible fields
|
||||||
|
visible_member_fields = FieldVisibility.get_visible_member_fields(final_selection)
|
||||||
|
visible_custom_fields = FieldVisibility.get_visible_custom_fields(final_selection)
|
||||||
|
|
||||||
|
socket =
|
||||||
|
socket
|
||||||
|
|> assign(:user_field_selection, final_selection)
|
||||||
|
|> assign(:member_fields_visible, visible_member_fields)
|
||||||
|
|> assign(:visible_custom_field_ids, extract_custom_field_ids(visible_custom_fields))
|
||||||
|
|> load_members()
|
||||||
|
|> prepare_dynamic_cols()
|
||||||
|
|> push_field_selection_url()
|
||||||
|
|
||||||
|
{:noreply, socket}
|
||||||
|
end
|
||||||
|
|
||||||
# -----------------------------------------------------------------
|
# -----------------------------------------------------------------
|
||||||
# Handle Params from the URL
|
# Handle Params from the URL
|
||||||
# -----------------------------------------------------------------
|
# -----------------------------------------------------------------
|
||||||
@doc """
|
@doc """
|
||||||
Handles URL parameter changes.
|
Handles URL parameter changes.
|
||||||
|
|
||||||
Parses query parameters for search query, sort field, sort order, and payment filter,
|
Parses query parameters for search query, sort field, sort order, and payment filter, and field selection,
|
||||||
then loads members accordingly. This enables bookmarkable URLs and
|
then loads members accordingly. This enables bookmarkable URLs and
|
||||||
browser back/forward navigation.
|
browser back/forward navigation.
|
||||||
"""
|
"""
|
||||||
@impl true
|
@impl true
|
||||||
def handle_params(params, _url, socket) do
|
def handle_params(params, _url, socket) do
|
||||||
|
# Parse field selection from URL
|
||||||
|
url_selection = FieldSelection.parse_from_url(params)
|
||||||
|
|
||||||
|
# Merge with session selection (URL has priority)
|
||||||
|
merged_selection =
|
||||||
|
FieldSelection.merge_sources(
|
||||||
|
url_selection,
|
||||||
|
socket.assigns.user_field_selection,
|
||||||
|
%{}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Merge with global settings (use all_custom_fields for merging)
|
||||||
|
final_selection =
|
||||||
|
FieldVisibility.merge_with_global_settings(
|
||||||
|
merged_selection,
|
||||||
|
socket.assigns.settings,
|
||||||
|
socket.assigns.all_custom_fields
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get visible fields
|
||||||
|
visible_member_fields = FieldVisibility.get_visible_member_fields(final_selection)
|
||||||
|
visible_custom_fields = FieldVisibility.get_visible_custom_fields(final_selection)
|
||||||
|
|
||||||
socket =
|
socket =
|
||||||
socket
|
socket
|
||||||
|> maybe_update_search(params)
|
|> maybe_update_search(params)
|
||||||
|> maybe_update_sort(params)
|
|> maybe_update_sort(params)
|
||||||
|> maybe_update_paid_filter(params)
|
|> maybe_update_paid_filter(params)
|
||||||
|> assign(:query, params["query"])
|
|> assign(:query, params["query"])
|
||||||
|
|> assign(:user_field_selection, final_selection)
|
||||||
|
|> assign(:member_fields_visible, visible_member_fields)
|
||||||
|
|> assign(:visible_custom_field_ids, extract_custom_field_ids(visible_custom_fields))
|
||||||
|> load_members()
|
|> load_members()
|
||||||
|> prepare_dynamic_cols()
|
|> prepare_dynamic_cols()
|
||||||
|
|
||||||
|
|
@ -280,10 +399,17 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
# - `:custom_field` - The CustomField resource
|
# - `:custom_field` - The CustomField resource
|
||||||
# - `:render` - A function that formats the custom field value for a given member
|
# - `:render` - A function that formats the custom field value for a given member
|
||||||
#
|
#
|
||||||
|
# Only includes custom fields that are visible according to user field selection.
|
||||||
|
#
|
||||||
# Returns the socket with `:dynamic_cols` assigned.
|
# Returns the socket with `:dynamic_cols` assigned.
|
||||||
defp prepare_dynamic_cols(socket) do
|
defp prepare_dynamic_cols(socket) do
|
||||||
|
visible_custom_field_ids = socket.assigns[:visible_custom_field_ids] || []
|
||||||
|
|
||||||
|
# Use all_custom_fields to allow users to enable globally hidden custom fields
|
||||||
dynamic_cols =
|
dynamic_cols =
|
||||||
Enum.map(socket.assigns.custom_fields_visible, fn custom_field ->
|
socket.assigns.all_custom_fields
|
||||||
|
|> Enum.filter(fn custom_field -> custom_field.id in visible_custom_field_ids end)
|
||||||
|
|> Enum.map(fn custom_field ->
|
||||||
%{
|
%{
|
||||||
custom_field: custom_field,
|
custom_field: custom_field,
|
||||||
render: fn member ->
|
render: fn member ->
|
||||||
|
|
@ -376,6 +502,58 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
)}
|
)}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Builds query parameters including field selection
|
||||||
|
defp build_query_params(socket, base_params) do
|
||||||
|
# Use query from base_params if provided, otherwise fall back to socket.assigns.query
|
||||||
|
query_value = Map.get(base_params, "query") || socket.assigns.query || ""
|
||||||
|
|
||||||
|
base_params
|
||||||
|
|> Map.put("query", query_value)
|
||||||
|
|> maybe_add_field_selection(socket.assigns[:user_field_selection])
|
||||||
|
end
|
||||||
|
|
||||||
|
# Adds field selection to query params if present
|
||||||
|
defp maybe_add_field_selection(params, nil), do: params
|
||||||
|
|
||||||
|
defp maybe_add_field_selection(params, selection) when is_map(selection) do
|
||||||
|
fields_param = FieldSelection.to_url_param(selection)
|
||||||
|
if fields_param != "", do: Map.put(params, "fields", fields_param), else: params
|
||||||
|
end
|
||||||
|
|
||||||
|
defp maybe_add_field_selection(params, _), do: params
|
||||||
|
|
||||||
|
# Pushes URL with updated field selection
|
||||||
|
defp push_field_selection_url(socket) do
|
||||||
|
base_params = %{
|
||||||
|
"sort_field" => field_to_string(socket.assigns.sort_field),
|
||||||
|
"sort_order" => Atom.to_string(socket.assigns.sort_order)
|
||||||
|
}
|
||||||
|
|
||||||
|
# Include paid_filter if set
|
||||||
|
base_params =
|
||||||
|
case socket.assigns.paid_filter do
|
||||||
|
nil -> base_params
|
||||||
|
:paid -> Map.put(base_params, "paid_filter", "paid")
|
||||||
|
:not_paid -> Map.put(base_params, "paid_filter", "not_paid")
|
||||||
|
end
|
||||||
|
|
||||||
|
query_params = build_query_params(socket, base_params)
|
||||||
|
new_path = ~p"/members?#{query_params}"
|
||||||
|
|
||||||
|
push_patch(socket, to: new_path, replace: true)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Converts field 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
|
||||||
|
|
||||||
|
# Updates session field selection (stored in socket for now, actual session update via controller)
|
||||||
|
defp update_session_field_selection(socket, selection) do
|
||||||
|
# Store in socket for now - actual session persistence would require a controller
|
||||||
|
# This is a placeholder for future session persistence
|
||||||
|
assign(socket, :user_field_selection, selection)
|
||||||
|
end
|
||||||
|
|
||||||
# Builds URL query parameters map including all filter/sort state.
|
# Builds URL query parameters map including all filter/sort state.
|
||||||
# Converts paid_filter atom to string for URL.
|
# Converts paid_filter atom to string for URL.
|
||||||
defp build_query_params(query, sort_field, sort_order, paid_filter) do
|
defp build_query_params(query, sort_field, sort_order, paid_filter) do
|
||||||
|
|
@ -434,9 +612,9 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
|> Ash.Query.new()
|
|> Ash.Query.new()
|
||||||
|> Ash.Query.select(@overview_fields)
|
|> Ash.Query.select(@overview_fields)
|
||||||
|
|
||||||
# Load custom field values for visible custom fields
|
# Load custom field values for visible custom fields (based on user selection)
|
||||||
custom_field_ids_list = Enum.map(socket.assigns.custom_fields_visible, & &1.id)
|
visible_custom_field_ids = socket.assigns[:visible_custom_field_ids] || []
|
||||||
query = load_custom_field_values(query, custom_field_ids_list)
|
query = load_custom_field_values(query, visible_custom_field_ids)
|
||||||
|
|
||||||
# Apply the search filter first
|
# Apply the search filter first
|
||||||
query = apply_search_filter(query, search_query)
|
query = apply_search_filter(query, search_query)
|
||||||
|
|
@ -490,7 +668,7 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
query
|
query
|
||||||
end
|
end
|
||||||
|
|
||||||
defp load_custom_field_values(query, custom_field_ids) when length(custom_field_ids) > 0 do
|
defp load_custom_field_values(query, custom_field_ids) do
|
||||||
# Filter custom field values at the database level using Ash relationship query
|
# Filter custom field values at the database level using Ash relationship query
|
||||||
# This ensures only visible custom field values are loaded
|
# This ensures only visible custom field values are loaded
|
||||||
custom_field_values_query =
|
custom_field_values_query =
|
||||||
|
|
@ -614,6 +792,18 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
|
|
||||||
defp extract_custom_field_id(_), do: nil
|
defp extract_custom_field_id(_), do: nil
|
||||||
|
|
||||||
|
# Extracts custom field IDs from visible custom field strings
|
||||||
|
# Format: "custom_field_<id>" -> <id>
|
||||||
|
defp extract_custom_field_ids(visible_custom_fields) do
|
||||||
|
Enum.map(visible_custom_fields, fn field_string ->
|
||||||
|
case String.split(field_string, @custom_field_prefix) do
|
||||||
|
["", id] -> id
|
||||||
|
_ -> nil
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|> Enum.filter(&(&1 != nil))
|
||||||
|
end
|
||||||
|
|
||||||
# Sorts members in memory by a custom field value.
|
# Sorts members in memory by a custom field value.
|
||||||
#
|
#
|
||||||
# Process:
|
# Process:
|
||||||
|
|
@ -892,6 +1082,16 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
|> Enum.map(&format_member_email/1)
|
|> Enum.map(&format_member_email/1)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Returns a JS command to toggle member selection when clicking the checkbox column.
|
||||||
|
|
||||||
|
Used as `col_click` handler to ensure clicking anywhere in the checkbox column
|
||||||
|
toggles the checkbox instead of navigating to the member details.
|
||||||
|
"""
|
||||||
|
def checkbox_column_click(member) do
|
||||||
|
JS.push("select_member", value: %{id: member.id})
|
||||||
|
end
|
||||||
|
|
||||||
# Formats a member's email in the format "First Last <email>"
|
# Formats a member's email in the format "First Last <email>"
|
||||||
# Used for copy_emails feature and mailto links to create email-client-friendly format.
|
# Used for copy_emails feature and mailto links to create email-client-friendly format.
|
||||||
def format_member_email(member) do
|
def format_member_email(member) do
|
||||||
|
|
@ -910,31 +1110,6 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# Gets the list of member fields that should be visible in the overview.
|
# Public helper function to format dates for use in templates
|
||||||
#
|
def format_date(date), do: DateFormatter.format_date(date)
|
||||||
# Reads the visibility configuration from Settings and returns only the fields
|
|
||||||
# where show_in_overview is true. Fields not configured in settings default to true.
|
|
||||||
#
|
|
||||||
# Performance: This function uses the already-loaded settings to avoid N+1 queries.
|
|
||||||
# Settings should be loaded once in mount/3 and passed to this function.
|
|
||||||
#
|
|
||||||
# Parameters:
|
|
||||||
# - `settings` - The settings struct loaded from the database
|
|
||||||
#
|
|
||||||
# Returns a list of atoms representing visible member field names.
|
|
||||||
#
|
|
||||||
# Fields are read from the global Constants module.
|
|
||||||
@spec get_visible_member_fields(map()) :: [atom()]
|
|
||||||
defp get_visible_member_fields(settings) do
|
|
||||||
# Get all eligible fields from the global constants
|
|
||||||
all_fields = Mv.Constants.member_fields()
|
|
||||||
|
|
||||||
# JSONB stores keys as strings
|
|
||||||
visibility_config = settings.member_field_visibility || %{}
|
|
||||||
|
|
||||||
# Filter to only return visible fields
|
|
||||||
Enum.filter(all_fields, fn field ->
|
|
||||||
Map.get(visibility_config, Atom.to_string(field), true)
|
|
||||||
end)
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,13 @@
|
||||||
paid_filter={@paid_filter}
|
paid_filter={@paid_filter}
|
||||||
member_count={length(@members)}
|
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>
|
</div>
|
||||||
|
|
||||||
<.table
|
<.table
|
||||||
|
|
@ -58,6 +65,7 @@
|
||||||
<!-- <:col :let={member} label="Id">{member.id}</:col> -->
|
<!-- <:col :let={member} label="Id">{member.id}</:col> -->
|
||||||
<:col
|
<:col
|
||||||
:let={member}
|
:let={member}
|
||||||
|
col_click={&MvWeb.MemberLive.Index.checkbox_column_click/1}
|
||||||
label={
|
label={
|
||||||
~H"""
|
~H"""
|
||||||
<.input
|
<.input
|
||||||
|
|
@ -74,17 +82,14 @@
|
||||||
<.input
|
<.input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
name={member.id}
|
name={member.id}
|
||||||
phx-click="select_member"
|
|
||||||
phx-value-id={member.id}
|
|
||||||
checked={MapSet.member?(@selected_members, member.id)}
|
checked={MapSet.member?(@selected_members, member.id)}
|
||||||
phx-capture-click
|
|
||||||
phx-stop-propagation
|
|
||||||
aria-label={gettext("Select member")}
|
aria-label={gettext("Select member")}
|
||||||
role="checkbox"
|
role="checkbox"
|
||||||
/>
|
/>
|
||||||
</:col>
|
</:col>
|
||||||
<:col
|
<:col
|
||||||
:let={member}
|
:let={member}
|
||||||
|
:if={:first_name in @member_fields_visible}
|
||||||
label={
|
label={
|
||||||
~H"""
|
~H"""
|
||||||
<.live_component
|
<.live_component
|
||||||
|
|
@ -98,7 +103,25 @@
|
||||||
"""
|
"""
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{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>
|
||||||
<:col
|
<:col
|
||||||
:let={member}
|
:let={member}
|
||||||
|
|
@ -224,9 +247,9 @@
|
||||||
"""
|
"""
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{member.join_date}
|
{MvWeb.MemberLive.Index.format_date(member.join_date)}
|
||||||
</:col>
|
</:col>
|
||||||
<:col :let={member} label={gettext("Paid")}>
|
<:col :let={member} :if={:paid in @member_fields_visible} label={gettext("Paid")}>
|
||||||
<span class={[
|
<span class={[
|
||||||
"badge",
|
"badge",
|
||||||
if(member.paid == true, do: "badge-success", else: "badge-error")
|
if(member.paid == true, do: "badge-success", else: "badge-error")
|
||||||
|
|
|
||||||
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
|
||||||
|
|
@ -6,6 +6,7 @@ defmodule MvWeb.MemberLive.Index.Formatter do
|
||||||
formats them appropriately for display in the UI.
|
formats them appropriately for display in the UI.
|
||||||
"""
|
"""
|
||||||
use Gettext, backend: MvWeb.Gettext
|
use Gettext, backend: MvWeb.Gettext
|
||||||
|
alias MvWeb.Helpers.DateFormatter
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Formats a custom field value for display.
|
Formats a custom field value for display.
|
||||||
|
|
@ -61,11 +62,11 @@ defmodule MvWeb.MemberLive.Index.Formatter do
|
||||||
defp format_value_by_type(value, :boolean, _) when value == false, do: gettext("No")
|
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(value, :boolean, _), do: to_string(value)
|
||||||
|
|
||||||
defp format_value_by_type(%Date{} = date, :date, _), do: Date.to_string(date)
|
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
|
defp format_value_by_type(value, :date, _) when is_binary(value) do
|
||||||
case Date.from_iso8601(value) do
|
case Date.from_iso8601(value) do
|
||||||
{:ok, date} -> Date.to_string(date)
|
{:ok, date} -> DateFormatter.format_date(date)
|
||||||
_ -> value
|
_ -> value
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -3,19 +3,16 @@ defmodule MvWeb.MemberLive.Show do
|
||||||
LiveView for displaying a single member's details.
|
LiveView for displaying a single member's details.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
- Display all member information (personal, contact, address)
|
- Display all member information in grouped sections
|
||||||
- Show linked user account (if exists)
|
- Tab navigation for future features (Payments)
|
||||||
- Display custom field values
|
- Show custom field values with type-based formatting
|
||||||
- Navigate to edit form
|
- Navigate to edit form
|
||||||
- Return to member list
|
- Return to member list
|
||||||
|
|
||||||
## Displayed Information
|
## Sections
|
||||||
- Basic: name, email, dates (join, exit)
|
- Personal Data: Name, address, contact information, membership dates, notes
|
||||||
- Contact: phone number
|
- Custom Fields: Dynamic fields in uniform grid layout (sorted by name)
|
||||||
- Address: street, house number, postal code, city
|
- Payment Data: Mockup section with placeholder data
|
||||||
- Status: paid flag
|
|
||||||
- Relationships: linked user account
|
|
||||||
- Custom: dynamic custom field values from CustomFields
|
|
||||||
|
|
||||||
## Navigation
|
## Navigation
|
||||||
- Back to member list
|
- Back to member list
|
||||||
|
|
@ -28,66 +25,150 @@ defmodule MvWeb.MemberLive.Show do
|
||||||
def render(assigns) do
|
def render(assigns) do
|
||||||
~H"""
|
~H"""
|
||||||
<Layouts.app flash={@flash} current_user={@current_user}>
|
<Layouts.app flash={@flash} current_user={@current_user}>
|
||||||
<.header>
|
<%!-- Header with Back button, Name, and Edit button --%>
|
||||||
{@member.first_name} {@member.last_name}
|
<div class="flex items-center justify-between gap-4 pb-4">
|
||||||
<:subtitle>{gettext("This is a member record from your database.")}</:subtitle>
|
<.button navigate={~p"/members"} aria-label={gettext("Back to members list")}>
|
||||||
|
<.icon name="hero-arrow-left" class="size-4" />
|
||||||
|
{gettext("Back")}
|
||||||
|
</.button>
|
||||||
|
|
||||||
<:actions>
|
<h1 class="text-2xl font-bold text-center flex-1">
|
||||||
<.button navigate={~p"/members"} aria-label={gettext("Back to members list")}>
|
{@member.first_name} {@member.last_name}
|
||||||
<.icon name="hero-arrow-left" />
|
</h1>
|
||||||
<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>
|
|
||||||
|
|
||||||
<.list>
|
<.button variant="primary" navigate={~p"/members/#{@member}/edit?return_to=show"}>
|
||||||
<:item title={gettext("Id")}>{@member.id}</:item>
|
{gettext("Edit Member")}
|
||||||
<:item title={gettext("First Name")}>{@member.first_name}</:item>
|
</.button>
|
||||||
<:item title={gettext("Last Name")}>{@member.last_name}</:item>
|
</div>
|
||||||
<:item title={gettext("Email")}>{@member.email}</: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>
|
|
||||||
|
|
||||||
<h3 class="mt-8 mb-2 text-lg font-semibold">{gettext("Custom Field Values")}</h3>
|
<%!-- Tab Navigation --%>
|
||||||
<.generic_list items={
|
<div role="tablist" class="tabs tabs-bordered mb-6">
|
||||||
Enum.map(@member.custom_field_values, fn cfv ->
|
<button role="tab" class="tab tab-active" aria-selected="true">
|
||||||
{
|
<.icon name="hero-identification" class="size-4 mr-2" />
|
||||||
# name
|
{gettext("Contact Data")}
|
||||||
cfv.custom_field && cfv.custom_field.name,
|
</button>
|
||||||
# value
|
<button role="tab" class="tab" disabled aria-disabled="true" title={gettext("Coming soon")}>
|
||||||
case cfv.value do
|
<.icon name="hero-credit-card" class="size-4 mr-2" />
|
||||||
%{value: v} -> v
|
{gettext("Payments")}
|
||||||
v -> v
|
</button>
|
||||||
end
|
</div>
|
||||||
}
|
|
||||||
end)
|
<%!-- 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>
|
</Layouts.app>
|
||||||
"""
|
"""
|
||||||
end
|
end
|
||||||
|
|
@ -114,4 +195,120 @@ defmodule MvWeb.MemberLive.Show do
|
||||||
|
|
||||||
defp page_title(:show), do: gettext("Show Member")
|
defp page_title(:show), do: gettext("Show Member")
|
||||||
defp page_title(:edit), do: gettext("Edit 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
|
end
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,7 @@ defmodule MvWeb.UserLive.Form do
|
||||||
<:subtitle>{gettext("Use this form to manage user records in your database.")}</:subtitle>
|
<:subtitle>{gettext("Use this form to manage user records in your database.")}</:subtitle>
|
||||||
</.header>
|
</.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" />
|
<.input field={@form[:email]} label={gettext("Email")} required type="email" />
|
||||||
|
|
||||||
<!-- Password Section -->
|
<!-- Password Section -->
|
||||||
|
|
@ -61,7 +61,7 @@ defmodule MvWeb.UserLive.Form do
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<%= if @show_password_fields do %>
|
<%= 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
|
<.input
|
||||||
field={@form[:password]}
|
field={@form[:password]}
|
||||||
label={gettext("Password")}
|
label={gettext("Password")}
|
||||||
|
|
@ -83,7 +83,7 @@ defmodule MvWeb.UserLive.Form do
|
||||||
|
|
||||||
<div class="text-sm text-gray-600">
|
<div class="text-sm text-gray-600">
|
||||||
<p><strong>{gettext("Password requirements")}:</strong></p>
|
<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("At least 8 characters")}</li>
|
||||||
<li>{gettext("Include both letters and numbers")}</li>
|
<li>{gettext("Include both letters and numbers")}</li>
|
||||||
<li>{gettext("Consider using special characters")}</li>
|
<li>{gettext("Consider using special characters")}</li>
|
||||||
|
|
@ -91,7 +91,7 @@ defmodule MvWeb.UserLive.Form do
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<%= if @user do %>
|
<%= 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">
|
<p class="text-sm text-orange-800">
|
||||||
<strong>{gettext("Admin Note")}:</strong> {gettext(
|
<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."
|
"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>
|
</div>
|
||||||
<% else %>
|
<% else %>
|
||||||
<%= if @user do %>
|
<%= 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">
|
<p class="text-sm text-blue-800">
|
||||||
<strong>{gettext("Note")}:</strong> {gettext(
|
<strong>{gettext("Note")}:</strong> {gettext(
|
||||||
"Check 'Change Password' above to set a new password for this user."
|
"Check 'Change Password' above to set a new password for this user."
|
||||||
|
|
@ -110,7 +110,7 @@ defmodule MvWeb.UserLive.Form do
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<% else %>
|
<% 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">
|
<p class="text-sm text-yellow-800">
|
||||||
<strong>{gettext("Note")}:</strong> {gettext(
|
<strong>{gettext("Note")}:</strong> {gettext(
|
||||||
"User will be created without a password. Check 'Set Password' to add one."
|
"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 -->
|
<!-- Member Linking Section -->
|
||||||
<div class="mt-6">
|
<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 %>
|
<%= if @user && @user.member && !@unlink_member do %>
|
||||||
<!-- Show linked member with unlink button -->
|
<!-- 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 class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p class="font-medium text-green-900">
|
<p class="font-medium text-green-900">
|
||||||
|
|
@ -147,7 +147,7 @@ defmodule MvWeb.UserLive.Form do
|
||||||
<% else %>
|
<% else %>
|
||||||
<%= if @unlink_member do %>
|
<%= if @unlink_member do %>
|
||||||
<!-- Show unlink pending message -->
|
<!-- 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">
|
<p class="text-sm text-yellow-800">
|
||||||
<strong>{gettext("Unlinking scheduled")}:</strong> {gettext(
|
<strong>{gettext("Unlinking scheduled")}:</strong> {gettext(
|
||||||
"Member will be unlinked when you save. Cannot select new member until saved."
|
"Member will be unlinked when you save. Cannot select new member until saved."
|
||||||
|
|
@ -219,7 +219,7 @@ defmodule MvWeb.UserLive.Form do
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<%= if @user && @user.email && @available_members != [] && Enum.all?(@available_members, &(&1.email == to_string(@user.email))) do %>
|
<%= 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">
|
<p class="text-sm text-yellow-800">
|
||||||
<strong>{gettext("Note")}:</strong> {gettext(
|
<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."
|
"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 %>
|
<%= if @selected_member_id && @selected_member_name do %>
|
||||||
<div
|
<div
|
||||||
id="member-selected"
|
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">
|
<p class="text-sm text-blue-800">
|
||||||
<strong>{gettext("Selected")}:</strong> {@selected_member_name}
|
<strong>{gettext("Selected")}:</strong> {@selected_member_name}
|
||||||
</p>
|
</p>
|
||||||
<p class="text-xs text-blue-600 mt-1">
|
<p class="mt-1 text-xs text-blue-600">
|
||||||
{gettext("Save to confirm linking.")}
|
{gettext("Save to confirm linking.")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -245,10 +245,12 @@ defmodule MvWeb.UserLive.Form do
|
||||||
<% end %>
|
<% end %>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<.button phx-disable-with={gettext("Saving...")} variant="primary">
|
<div class="mt-4">
|
||||||
{gettext("Save User")}
|
<.button phx-disable-with={gettext("Saving...")} variant="primary">
|
||||||
</.button>
|
{gettext("Save User")}
|
||||||
<.button navigate={return_path(@return_to, @user)}>{gettext("Cancel")}</.button>
|
</.button>
|
||||||
|
<.button navigate={return_path(@return_to, @user)}>{gettext("Cancel")}</.button>
|
||||||
|
</div>
|
||||||
</.form>
|
</.form>
|
||||||
</Layouts.app>
|
</Layouts.app>
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -49,7 +49,6 @@
|
||||||
>
|
>
|
||||||
{user.email}
|
{user.email}
|
||||||
</:col>
|
</:col>
|
||||||
<:col :let={user} label={gettext("OIDC ID")}>{user.oidc_id}</:col>
|
|
||||||
<:col :let={user} label={gettext("Linked Member")}>
|
<:col :let={user} label={gettext("Linked Member")}>
|
||||||
<%= if user.member do %>
|
<%= if user.member do %>
|
||||||
{user.member.first_name} {user.member.last_name}
|
{user.member.first_name} {user.member.last_name}
|
||||||
|
|
|
||||||
|
|
@ -46,9 +46,7 @@ defmodule MvWeb.UserLive.Show do
|
||||||
</.header>
|
</.header>
|
||||||
|
|
||||||
<.list>
|
<.list>
|
||||||
<:item title={gettext("ID")}>{@user.id}</:item>
|
|
||||||
<:item title={gettext("Email")}>{@user.email}</: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")}>
|
<:item title={gettext("Password Authentication")}>
|
||||||
{if @user.hashed_password, do: gettext("Enabled"), else: gettext("Not enabled")}
|
{if @user.hashed_password, do: gettext("Enabled"), else: gettext("Not enabled")}
|
||||||
</:item>
|
</:item>
|
||||||
|
|
@ -56,13 +54,13 @@ defmodule MvWeb.UserLive.Show do
|
||||||
<%= if @user.member do %>
|
<%= if @user.member do %>
|
||||||
<.link
|
<.link
|
||||||
navigate={~p"/members/#{@user.member}"}
|
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}
|
{@user.member.first_name} {@user.member.last_name}
|
||||||
</.link>
|
</.link>
|
||||||
<% else %>
|
<% else %>
|
||||||
<span class="text-gray-500 italic">{gettext("No member linked")}</span>
|
<span class="italic text-gray-500">{gettext("No member linked")}</span>
|
||||||
<% end %>
|
<% end %>
|
||||||
</:item>
|
</:item>
|
||||||
</.list>
|
</.list>
|
||||||
|
|
|
||||||
|
|
@ -55,12 +55,6 @@ defmodule MvWeb.Router do
|
||||||
live "/members/:id", MemberLive.Show, :show
|
live "/members/:id", MemberLive.Show, :show
|
||||||
live "/members/:id/show/edit", MemberLive.Show, :edit
|
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", CustomFieldValueLive.Index, :index
|
||||||
live "/custom_field_values/new", CustomFieldValueLive.Form, :new
|
live "/custom_field_values/new", CustomFieldValueLive.Form, :new
|
||||||
live "/custom_field_values/:id/edit", CustomFieldValueLive.Form, :edit
|
live "/custom_field_values/:id/edit", CustomFieldValueLive.Form, :edit
|
||||||
|
|
@ -75,6 +69,11 @@ defmodule MvWeb.Router do
|
||||||
|
|
||||||
live "/settings", GlobalSettingsLive
|
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
|
post "/set_locale", LocaleController, :set_locale
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
21
lib/mv_web/translations/field_types.ex
Normal file
21
lib/mv_web/translations/field_types.ex
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
defmodule MvWeb.Translations.FieldTypes do
|
||||||
|
@moduledoc """
|
||||||
|
Helper module to dynamically translate field types.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
- Can be used in templates to dynamically translate technical field type words to human friendly text
|
||||||
|
|
||||||
|
## Example
|
||||||
|
assigns = assign(assigns, :field_type_label, &MvWeb.Translations.FieldTypes.label/1)
|
||||||
|
In template:
|
||||||
|
<%= @field_type_label.(custom_field.value_type) %>
|
||||||
|
"""
|
||||||
|
use Gettext, backend: MvWeb.Gettext
|
||||||
|
|
||||||
|
@spec label(atom()) :: String.t()
|
||||||
|
def label(:string), do: gettext("Text")
|
||||||
|
def label(:integer), do: gettext("Number")
|
||||||
|
def label(:boolean), do: gettext("Yes/No-Selection")
|
||||||
|
def label(:date), do: gettext("Date")
|
||||||
|
def label(:email), do: gettext("E-Mail")
|
||||||
|
end
|
||||||
41
lib/mv_web/translations/member_fields.ex
Normal file
41
lib/mv_web/translations/member_fields.ex
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
defmodule MvWeb.Translations.MemberFields do
|
||||||
|
@moduledoc """
|
||||||
|
Helper module to dynamically translate member field names.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
- Translates technical field names (atoms) to human-friendly localized text
|
||||||
|
- Used primarily in the field visibility dropdown component
|
||||||
|
|
||||||
|
## Example
|
||||||
|
|
||||||
|
iex> MvWeb.Translations.MemberFields.label(:first_name)
|
||||||
|
"Vorname" # when locale is "de"
|
||||||
|
|
||||||
|
iex> MvWeb.Translations.MemberFields.label(:first_name)
|
||||||
|
"First Name" # when locale is "en"
|
||||||
|
"""
|
||||||
|
use Gettext, backend: MvWeb.Gettext
|
||||||
|
|
||||||
|
@spec label(atom()) :: String.t()
|
||||||
|
def label(:first_name), do: gettext("First Name")
|
||||||
|
def label(:last_name), do: gettext("Last Name")
|
||||||
|
def label(:email), do: gettext("Email")
|
||||||
|
def label(:paid), do: gettext("Paid")
|
||||||
|
def label(:phone_number), do: gettext("Phone")
|
||||||
|
def label(:join_date), do: gettext("Join Date")
|
||||||
|
def label(:exit_date), do: gettext("Exit Date")
|
||||||
|
def label(:notes), do: gettext("Notes")
|
||||||
|
def label(:city), do: gettext("City")
|
||||||
|
def label(:street), do: gettext("Street")
|
||||||
|
def label(:house_number), do: gettext("House Number")
|
||||||
|
def label(:postal_code), do: gettext("Postal Code")
|
||||||
|
|
||||||
|
# Fallback for unknown fields
|
||||||
|
def label(field) do
|
||||||
|
field
|
||||||
|
|> to_string()
|
||||||
|
|> String.replace("_", " ")
|
||||||
|
|> String.split()
|
||||||
|
|> Enum.map_join(" ", &String.capitalize/1)
|
||||||
|
end
|
||||||
|
end
|
||||||
9
mix.exs
9
mix.exs
|
|
@ -12,7 +12,8 @@ defmodule Mv.MixProject do
|
||||||
compilers: [:phoenix_live_view] ++ Mix.compilers(),
|
compilers: [:phoenix_live_view] ++ Mix.compilers(),
|
||||||
aliases: aliases(),
|
aliases: aliases(),
|
||||||
deps: deps(),
|
deps: deps(),
|
||||||
listeners: [Phoenix.CodeReloader]
|
listeners: [Phoenix.CodeReloader],
|
||||||
|
gettext: [write_reference_line_numbers: false]
|
||||||
]
|
]
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -37,7 +38,7 @@ defmodule Mv.MixProject do
|
||||||
[
|
[
|
||||||
{:tidewave, "~> 0.5", only: [:dev]},
|
{:tidewave, "~> 0.5", only: [:dev]},
|
||||||
{:sourceror, "~> 1.8", only: [:dev, :test]},
|
{:sourceror, "~> 1.8", only: [:dev, :test]},
|
||||||
{:live_debugger, "~> 0.4", only: [:dev]},
|
{:live_debugger, "~> 0.5", only: [:dev]},
|
||||||
{:ash_admin, "~> 0.13"},
|
{:ash_admin, "~> 0.13"},
|
||||||
{:ash_postgres, "~> 2.0"},
|
{:ash_postgres, "~> 2.0"},
|
||||||
{:ash_phoenix, "~> 2.0"},
|
{:ash_phoenix, "~> 2.0"},
|
||||||
|
|
@ -45,7 +46,7 @@ defmodule Mv.MixProject do
|
||||||
{:bcrypt_elixir, "~> 3.0"},
|
{:bcrypt_elixir, "~> 3.0"},
|
||||||
{:ash_authentication, "~> 4.9"},
|
{:ash_authentication, "~> 4.9"},
|
||||||
{:ash_authentication_phoenix, "~> 2.10"},
|
{:ash_authentication_phoenix, "~> 2.10"},
|
||||||
{:igniter, "~> 0.6", only: [:dev, :test]},
|
{:igniter, "~> 0.7", only: [:dev, :test]},
|
||||||
{:phoenix, "~> 1.8.0-rc.4", override: true},
|
{:phoenix, "~> 1.8.0-rc.4", override: true},
|
||||||
{:phoenix_ecto, "~> 4.5"},
|
{:phoenix_ecto, "~> 4.5"},
|
||||||
{:ecto_sql, "~> 3.10"},
|
{:ecto_sql, "~> 3.10"},
|
||||||
|
|
@ -68,7 +69,7 @@ defmodule Mv.MixProject do
|
||||||
{:req, "~> 0.5"},
|
{:req, "~> 0.5"},
|
||||||
{:telemetry_metrics, "~> 1.0"},
|
{:telemetry_metrics, "~> 1.0"},
|
||||||
{:telemetry_poller, "~> 1.0"},
|
{:telemetry_poller, "~> 1.0"},
|
||||||
{:gettext, "~> 0.26"},
|
{:gettext, "~> 1.0"},
|
||||||
{:jason, "~> 1.2"},
|
{:jason, "~> 1.2"},
|
||||||
{:dns_cluster, "~> 0.2.0"},
|
{:dns_cluster, "~> 0.2.0"},
|
||||||
{:bandit, "~> 1.5"},
|
{:bandit, "~> 1.5"},
|
||||||
|
|
|
||||||
56
mix.lock
56
mix.lock
|
|
@ -1,32 +1,32 @@
|
||||||
%{
|
%{
|
||||||
"ash": {:hex, :ash, "3.7.1", "abb55dee19e0959e529e52fe0622468825ae05400f535484919713e492d9a9e7", [:mix], [{:crux, "~> 0.1.0", [hex: :crux, repo: "hexpm", optional: false]}, {:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.7", [hex: :ecto, repo: "hexpm", optional: false]}, {:ets, "~> 0.8", [hex: :ets, repo: "hexpm", optional: false]}, {:igniter, ">= 0.6.29 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: false]}, {:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}, {:plug, ">= 0.0.0", [hex: :plug, repo: "hexpm", optional: true]}, {:reactor, "~> 0.11", [hex: :reactor, repo: "hexpm", optional: false]}, {:simple_sat, ">= 0.1.1 and < 1.0.0-0", [hex: :simple_sat, repo: "hexpm", optional: true]}, {:spark, ">= 2.3.3 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, ">= 0.2.6 and < 1.0.0-0", [hex: :splode, repo: "hexpm", optional: false]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.1", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "4474ce9befe9862d1ed73cadf8a755e836c45a14a7b3b952d02e1a12f2b2e529"},
|
"ash": {:hex, :ash, "3.11.1", "9794620bffeb83d1803d92a64e7803f70b57372eb4addba5c12a24343cd04e1a", [:mix], [{:crux, ">= 0.1.2 and < 1.0.0-0", [hex: :crux, repo: "hexpm", optional: false]}, {:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.7", [hex: :ecto, repo: "hexpm", optional: false]}, {:ets, "~> 0.8", [hex: :ets, repo: "hexpm", optional: false]}, {:igniter, ">= 0.6.29 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: false]}, {:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}, {:plug, ">= 0.0.0", [hex: :plug, repo: "hexpm", optional: true]}, {:reactor, "~> 0.11", [hex: :reactor, repo: "hexpm", optional: false]}, {:simple_sat, ">= 0.1.1 and < 1.0.0-0", [hex: :simple_sat, repo: "hexpm", optional: true]}, {:spark, ">= 2.3.14 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, ">= 0.2.6 and < 1.0.0-0", [hex: :splode, repo: "hexpm", optional: false]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.1", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e0074302bb88d667635fcbfdacbf8a641c53973a3902d0e744f567a49ec808fc"},
|
||||||
"ash_admin": {:hex, :ash_admin, "0.13.19", "43227905381ea0b835039fb3f3d255a3664925619937869e605402bc2f95c5e5", [:mix], [{:ash, ">= 3.4.63 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_phoenix, ">= 2.1.8 and < 3.0.0-0", [hex: :ash_phoenix, repo: "hexpm", optional: false]}, {:gettext, "~> 0.26", [hex: :gettext, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.7", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.1-rc", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}], "hexpm", "41e6262c437164df6f052e43cc93be225a7e148b49a813fc451e70172338ee38"},
|
"ash_admin": {:hex, :ash_admin, "0.13.24", "4fafddc7b4450a92878b58630688c55cab20b0c27e35cad68f29811f73815816", [:mix], [{:ash, ">= 3.4.63 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_phoenix, ">= 2.1.8 and < 3.0.0-0", [hex: :ash_phoenix, repo: "hexpm", optional: false]}, {:gettext, "~> 0.26 or ~> 1.0", [hex: :gettext, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.7", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.1-rc", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}], "hexpm", "8f298cf6cb6635744ed75dd401295ed52513ea4df169f0f89d6a9a75dc4de4dc"},
|
||||||
"ash_authentication": {:hex, :ash_authentication, "4.11.0", "4165ede37e179cb0a24b7bfc38d620fa93c05fb6272fbd353cafe27652b1e68b", [:mix], [{:argon2_elixir, "~> 4.0", [hex: :argon2_elixir, repo: "hexpm", optional: true]}, {:ash, ">= 3.4.29 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_postgres, ">= 2.6.8 and < 3.0.0-0", [hex: :ash_postgres, repo: "hexpm", optional: true]}, {:assent, "~> 0.2.13", [hex: :assent, repo: "hexpm", optional: false]}, {:bcrypt_elixir, "~> 3.0", [hex: :bcrypt_elixir, repo: "hexpm", optional: false]}, {:castore, "~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:finch, "~> 0.19", [hex: :finch, repo: "hexpm", optional: false]}, {:igniter, "~> 0.4", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:joken, "~> 2.5", [hex: :joken, repo: "hexpm", optional: false]}, {:plug, "~> 1.13", [hex: :plug, repo: "hexpm", optional: false]}, {:spark, "~> 2.0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.2", [hex: :splode, repo: "hexpm", optional: false]}], "hexpm", "8201d0169944c1df3db9b560494e50e1c3bc99c3b1a8a2ef1e61b0f77bc820df"},
|
"ash_authentication": {:hex, :ash_authentication, "4.13.3", "4d7a2e96b5a8fe68797ba0124cf40e6897c82b9fb69182fc5fdaac529b72d436", [:mix], [{:argon2_elixir, "~> 4.0", [hex: :argon2_elixir, repo: "hexpm", optional: true]}, {:ash, "~> 3.7", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_postgres, ">= 2.6.8 and < 3.0.0-0", [hex: :ash_postgres, repo: "hexpm", optional: true]}, {:assent, "> 0.2.0 and < 0.3.0", [hex: :assent, repo: "hexpm", optional: false]}, {:bcrypt_elixir, "~> 3.0", [hex: :bcrypt_elixir, repo: "hexpm", optional: false]}, {:castore, "~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:finch, "~> 0.19", [hex: :finch, repo: "hexpm", optional: false]}, {:igniter, "~> 0.4", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:joken, "~> 2.5", [hex: :joken, repo: "hexpm", optional: false]}, {:plug, "~> 1.13", [hex: :plug, repo: "hexpm", optional: false]}, {:spark, "~> 2.0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.2", [hex: :splode, repo: "hexpm", optional: false]}], "hexpm", "03d95b68766b28cda241e68217f6d1d839be350f7e8f20923162b163fb521b91"},
|
||||||
"ash_authentication_phoenix": {:hex, :ash_authentication_phoenix, "2.12.0", "75d7d77e3b626f3d8ea6ee44291d885950172ab399d997b2934f93d2e0a55a61", [:mix], [{:ash, "~> 3.0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_authentication, "~> 4.10", [hex: :ash_authentication, repo: "hexpm", optional: false]}, {:ash_phoenix, ">= 2.3.11 and < 3.0.0-0", [hex: :ash_phoenix, repo: "hexpm", optional: false]}, {:bcrypt_elixir, "~> 3.0", [hex: :bcrypt_elixir, repo: "hexpm", optional: false]}, {:gettext, "~> 0.26 or ~> 1.0", [hex: :gettext, repo: "hexpm", optional: true]}, {:igniter, ">= 0.5.25 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.6", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_html_helpers, "~> 1.0", [hex: :phoenix_html_helpers, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}, {:slugify, "~> 1.3", [hex: :slugify, repo: "hexpm", optional: false]}], "hexpm", "a423e22b40fdf3b1a7f2178e44ca68f48fdb5ba0d87e8d42a43de1a3b63ca704"},
|
"ash_authentication_phoenix": {:hex, :ash_authentication_phoenix, "2.12.2", "a4646498a7e21fbdbe372f0d8afab08b5d7125b629f91bfcf8f4d1961bc9d57b", [:mix], [{:ash, "~> 3.0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_authentication, "~> 4.10", [hex: :ash_authentication, repo: "hexpm", optional: false]}, {:ash_phoenix, ">= 2.3.11 and < 3.0.0-0", [hex: :ash_phoenix, repo: "hexpm", optional: false]}, {:bcrypt_elixir, "~> 3.0", [hex: :bcrypt_elixir, repo: "hexpm", optional: false]}, {:gettext, "~> 0.26 or ~> 1.0", [hex: :gettext, repo: "hexpm", optional: true]}, {:igniter, ">= 0.5.25 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.6", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_html_helpers, "~> 1.0", [hex: :phoenix_html_helpers, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.1", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}, {:slugify, "~> 1.3", [hex: :slugify, repo: "hexpm", optional: false]}], "hexpm", "1dd6fa3a8f7d2563a53cf22aeda31770c855e927421af4d8bfaf480332acf721"},
|
||||||
"ash_phoenix": {:hex, :ash_phoenix, "2.3.17", "a074ae6d9d7135d99c4edc91ddebe4c035ca380b044592bf9c3d58471669cf52", [:mix], [{:ash, ">= 3.5.13 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:igniter, "~> 0.6", [hex: :igniter, repo: "hexpm", optional: true]}, {:inertia, "~> 2.3", [hex: :inertia, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.5.6 or ~> 1.6", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.20.3 or ~> 1.0-rc.1", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:spark, ">= 2.2.29 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}], "hexpm", "94e4a6cc6ced31cddba930c45c1c3477aa59b956e7fc3cdc63095cf0e506bdf5"},
|
"ash_phoenix": {:hex, :ash_phoenix, "2.3.18", "fad1b8af1405758888086de568f08650c2911ee97074cfe2e325b14854bc43dd", [:mix], [{:ash, ">= 3.5.13 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:igniter, "~> 0.6", [hex: :igniter, repo: "hexpm", optional: true]}, {:inertia, "~> 2.3", [hex: :inertia, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.5.6 or ~> 1.6", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.20.3 or ~> 1.0-rc.1", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:spark, ">= 2.2.29 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}], "hexpm", "7ec28f9216221e83b90d9c3605e9d1cdd228984e09a1a86c9b9d393cebf25222"},
|
||||||
"ash_postgres": {:hex, :ash_postgres, "2.6.23", "5976a7e5e204b7bc627b1d17026bec9da4d880f2e09cd94bf4e8cee41fef32ce", [:mix], [{:ash, "~> 3.7", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_sql, ">= 0.3.7 and < 1.0.0-0", [hex: :ash_sql, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.13", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:igniter, ">= 0.6.29 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:postgrex, ">= 0.0.0", [hex: :postgrex, repo: "hexpm", optional: false]}, {:spark, ">= 2.3.4 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}], "hexpm", "61de4aedfe30f1ae14d8185cfc37a5b1940b45b60f2dfbdf9eb056f97dca41c5"},
|
"ash_postgres": {:hex, :ash_postgres, "2.6.26", "f995bac8762ae039d4fb94cf2b628430aa69b0b30bf4366b96b3543dbd679ae7", [:mix], [{:ash, "~> 3.9", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_sql, ">= 0.3.12 and < 1.0.0-0", [hex: :ash_sql, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.13", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:igniter, ">= 0.6.29 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:postgrex, ">= 0.0.0", [hex: :postgrex, repo: "hexpm", optional: false]}, {:spark, ">= 2.3.4 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}], "hexpm", "7050b3169d5a31d73f7e69a6564d1102cb2bc185e67ea428e78fda3da46a69fc"},
|
||||||
"ash_sql": {:hex, :ash_sql, "0.3.7", "80affa5446075d71deb157c67290685a84b392d723be766bfb684f58fe0143de", [:mix], [{:ash, "~> 3.7", [hex: :ash, repo: "hexpm", optional: false]}, {:ecto, "~> 3.9", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.9", [hex: :ecto_sql, repo: "hexpm", optional: false]}], "hexpm", "ce4d974b8e784171c5a2a62593b3672b42dfd4888fa2239f01a6b32bad769038"},
|
"ash_sql": {:hex, :ash_sql, "0.3.15", "8b8daae1870ab37b4fb2f980e323194caf23cdb4218fef126c49cc11a01fa243", [:mix], [{:ash, "~> 3.7", [hex: :ash, repo: "hexpm", optional: false]}, {:ecto, ">= 3.13.4 and < 4.0.0-0", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.9", [hex: :ecto_sql, repo: "hexpm", optional: false]}], "hexpm", "97432507b6f406eb2461e5d0fbf2e5104a8c61a2570322d11de2f124d822d8ff"},
|
||||||
"assent": {:hex, :assent, "0.2.13", "11226365d2d8661d23e9a2cf94d3255e81054ff9d88ac877f28bfdf38fa4ef31", [:mix], [{:certifi, ">= 0.0.0", [hex: :certifi, repo: "hexpm", optional: true]}, {:finch, "~> 0.15", [hex: :finch, repo: "hexpm", optional: true]}, {:jose, "~> 1.8", [hex: :jose, repo: "hexpm", optional: true]}, {:mint, "~> 1.0", [hex: :mint, repo: "hexpm", optional: true]}, {:req, "~> 0.4", [hex: :req, repo: "hexpm", optional: true]}, {:ssl_verify_fun, ">= 0.0.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: true]}], "hexpm", "bf9f351b01dd6bceea1d1f157f05438f6765ce606e6eb8d29296003d29bf6eab"},
|
"assent": {:hex, :assent, "0.2.13", "11226365d2d8661d23e9a2cf94d3255e81054ff9d88ac877f28bfdf38fa4ef31", [:mix], [{:certifi, ">= 0.0.0", [hex: :certifi, repo: "hexpm", optional: true]}, {:finch, "~> 0.15", [hex: :finch, repo: "hexpm", optional: true]}, {:jose, "~> 1.8", [hex: :jose, repo: "hexpm", optional: true]}, {:mint, "~> 1.0", [hex: :mint, repo: "hexpm", optional: true]}, {:req, "~> 0.4", [hex: :req, repo: "hexpm", optional: true]}, {:ssl_verify_fun, ">= 0.0.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: true]}], "hexpm", "bf9f351b01dd6bceea1d1f157f05438f6765ce606e6eb8d29296003d29bf6eab"},
|
||||||
"bandit": {:hex, :bandit, "1.8.0", "c2e93d7e3c5c794272fa4623124f827c6f24b643acc822be64c826f9447d92fb", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "8458ff4eed20ff2a2ea69d4854883a077c33ea42b51f6811b044ceee0fa15422"},
|
"bandit": {:hex, :bandit, "1.8.0", "c2e93d7e3c5c794272fa4623124f827c6f24b643acc822be64c826f9447d92fb", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "8458ff4eed20ff2a2ea69d4854883a077c33ea42b51f6811b044ceee0fa15422"},
|
||||||
"bcrypt_elixir": {:hex, :bcrypt_elixir, "3.3.2", "d50091e3c9492d73e17fc1e1619a9b09d6a5ef99160eb4d736926fd475a16ca3", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "471be5151874ae7931911057d1467d908955f93554f7a6cd1b7d804cac8cef53"},
|
"bcrypt_elixir": {:hex, :bcrypt_elixir, "3.3.2", "d50091e3c9492d73e17fc1e1619a9b09d6a5ef99160eb4d736926fd475a16ca3", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "471be5151874ae7931911057d1467d908955f93554f7a6cd1b7d804cac8cef53"},
|
||||||
"bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"},
|
"bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"},
|
||||||
"castore": {:hex, :castore, "1.0.15", "8aa930c890fe18b6fe0a0cff27b27d0d4d231867897bd23ea772dee561f032a3", [:mix], [], "hexpm", "96ce4c69d7d5d7a0761420ef743e2f4096253931a3ba69e5ff8ef1844fe446d3"},
|
"castore": {:hex, :castore, "1.0.17", "4f9770d2d45fbd91dcf6bd404cf64e7e58fed04fadda0923dc32acca0badffa2", [:mix], [], "hexpm", "12d24b9d80b910dd3953e165636d68f147a31db945d2dcb9365e441f8b5351e5"},
|
||||||
"cc_precompiler": {:hex, :cc_precompiler, "0.1.11", "8c844d0b9fb98a3edea067f94f616b3f6b29b959b6b3bf25fee94ffe34364768", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "3427232caf0835f94680e5bcf082408a70b48ad68a5f5c0b02a3bea9f3a075b9"},
|
"cc_precompiler": {:hex, :cc_precompiler, "0.1.11", "8c844d0b9fb98a3edea067f94f616b3f6b29b959b6b3bf25fee94ffe34364768", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "3427232caf0835f94680e5bcf082408a70b48ad68a5f5c0b02a3bea9f3a075b9"},
|
||||||
"circular_buffer": {:hex, :circular_buffer, "1.0.0", "25c004da0cba7bd8bc1bdabded4f9a902d095e20600fd15faf1f2ffbaea18a07", [:mix], [], "hexpm", "c829ec31c13c7bafd1f546677263dff5bfb006e929f25635878ac3cfba8749e5"},
|
"circular_buffer": {:hex, :circular_buffer, "1.0.0", "25c004da0cba7bd8bc1bdabded4f9a902d095e20600fd15faf1f2ffbaea18a07", [:mix], [], "hexpm", "c829ec31c13c7bafd1f546677263dff5bfb006e929f25635878ac3cfba8749e5"},
|
||||||
"comeonin": {:hex, :comeonin, "5.5.1", "5113e5f3800799787de08a6e0db307133850e635d34e9fab23c70b6501669510", [:mix], [], "hexpm", "65aac8f19938145377cee73973f192c5645873dcf550a8a6b18187d17c13ccdb"},
|
"comeonin": {:hex, :comeonin, "5.5.1", "5113e5f3800799787de08a6e0db307133850e635d34e9fab23c70b6501669510", [:mix], [], "hexpm", "65aac8f19938145377cee73973f192c5645873dcf550a8a6b18187d17c13ccdb"},
|
||||||
"credo": {:hex, :credo, "1.7.13", "126a0697df6b7b71cd18c81bc92335297839a806b6f62b61d417500d1070ff4e", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "47641e6d2bbff1e241e87695b29f617f1a8f912adea34296fb10ecc3d7e9e84f"},
|
"credo": {:hex, :credo, "1.7.14", "c7e75216cea8d978ba8c60ed9dede4cc79a1c99a266c34b3600dd2c33b96bc92", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "12a97d6bb98c277e4fb1dff45aaf5c137287416009d214fb46e68147bd9e0203"},
|
||||||
"crux": {:hex, :crux, "0.1.1", "94f2f97d2a6079ae3c57f356412bc3b307f9579a80e43f526447b1d508dd4a72", [:mix], [{:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}, {:simple_sat, ">= 0.1.1 and < 1.0.0-0", [hex: :simple_sat, repo: "hexpm", optional: true]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: true]}], "hexpm", "e59d498f038193cbe31e448f9199f5b4c53a4c67cece9922bb839595189dd2b6"},
|
"crux": {:hex, :crux, "0.1.2", "4441c9e3a34f1e340954ce96b9ad5a2de13ceb4f97b3f910211227bb92e2ca90", [:mix], [{:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}, {:simple_sat, ">= 0.1.1 and < 1.0.0-0", [hex: :simple_sat, repo: "hexpm", optional: true]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: true]}], "hexpm", "563ea3748ebfba9cc078e6d198a1d6a06015a8fae503f0b721363139f0ddb350"},
|
||||||
"db_connection": {:hex, :db_connection, "2.8.1", "9abdc1e68c34c6163f6fb96a96532272d13ad7ca45262156ae8b7ec6d9dc4bec", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a61a3d489b239d76f326e03b98794fb8e45168396c925ef25feb405ed09da8fd"},
|
"db_connection": {:hex, :db_connection, "2.8.1", "9abdc1e68c34c6163f6fb96a96532272d13ad7ca45262156ae8b7ec6d9dc4bec", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a61a3d489b239d76f326e03b98794fb8e45168396c925ef25feb405ed09da8fd"},
|
||||||
"decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"},
|
"decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"},
|
||||||
"dns_cluster": {:hex, :dns_cluster, "0.2.0", "aa8eb46e3bd0326bd67b84790c561733b25c5ba2fe3c7e36f28e88f384ebcb33", [:mix], [], "hexpm", "ba6f1893411c69c01b9e8e8f772062535a4cf70f3f35bcc964a324078d8c8240"},
|
"dns_cluster": {:hex, :dns_cluster, "0.2.0", "aa8eb46e3bd0326bd67b84790c561733b25c5ba2fe3c7e36f28e88f384ebcb33", [:mix], [], "hexpm", "ba6f1893411c69c01b9e8e8f772062535a4cf70f3f35bcc964a324078d8c8240"},
|
||||||
"ecto": {:hex, :ecto, "3.13.3", "6a983f0917f8bdc7a89e96f2bf013f220503a0da5d8623224ba987515b3f0d80", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "1927db768f53a88843ff25b6ba7946599a8ca8a055f69ad8058a1432a399af94"},
|
"ecto": {:hex, :ecto, "3.13.5", "9d4a69700183f33bf97208294768e561f5c7f1ecf417e0fa1006e4a91713a834", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "df9efebf70cf94142739ba357499661ef5dbb559ef902b68ea1f3c1fabce36de"},
|
||||||
"ecto_commons": {:hex, :ecto_commons, "0.3.6", "7b1d9e59396cf8c8cbe5a26d50d03f9b6d0fe6c640210dd503622f276f1e59bb", [:mix], [{:burnex, "~> 3.0", [hex: :burnex, repo: "hexpm", optional: true]}, {:ecto, "~> 3.4", [hex: :ecto, repo: "hexpm", optional: false]}, {:ex_phone_number, "~> 0.2", [hex: :ex_phone_number, repo: "hexpm", optional: false]}, {:luhn, "~> 0.3.0", [hex: :luhn, repo: "hexpm", optional: false]}], "hexpm", "3f12981a1e398f206c5d2014e7b732b7ec91b110b9cb84875cb5b28fc75d7a0a"},
|
"ecto_commons": {:hex, :ecto_commons, "0.3.6", "7b1d9e59396cf8c8cbe5a26d50d03f9b6d0fe6c640210dd503622f276f1e59bb", [:mix], [{:burnex, "~> 3.0", [hex: :burnex, repo: "hexpm", optional: true]}, {:ecto, "~> 3.4", [hex: :ecto, repo: "hexpm", optional: false]}, {:ex_phone_number, "~> 0.2", [hex: :ex_phone_number, repo: "hexpm", optional: false]}, {:luhn, "~> 0.3.0", [hex: :luhn, repo: "hexpm", optional: false]}], "hexpm", "3f12981a1e398f206c5d2014e7b732b7ec91b110b9cb84875cb5b28fc75d7a0a"},
|
||||||
"ecto_sql": {:hex, :ecto_sql, "3.13.2", "a07d2461d84107b3d037097c822ffdd36ed69d1cf7c0f70e12a3d1decf04e2e1", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "539274ab0ecf1a0078a6a72ef3465629e4d6018a3028095dc90f60a19c371717"},
|
"ecto_sql": {:hex, :ecto_sql, "3.13.3", "81f7067dd1951081888529002dbc71f54e5e891b69c60195040ea44697e1104a", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "5751caea36c8f5dd0d1de6f37eceffea19d10bd53f20e5bbe31c45f2efc8944a"},
|
||||||
"elixir_make": {:hex, :elixir_make, "0.9.0", "6484b3cd8c0cee58f09f05ecaf1a140a8c97670671a6a0e7ab4dc326c3109726", [:mix], [], "hexpm", "db23d4fd8b757462ad02f8aa73431a426fe6671c80b200d9710caf3d1dd0ffdb"},
|
"elixir_make": {:hex, :elixir_make, "0.9.0", "6484b3cd8c0cee58f09f05ecaf1a140a8c97670671a6a0e7ab4dc326c3109726", [:mix], [], "hexpm", "db23d4fd8b757462ad02f8aa73431a426fe6671c80b200d9710caf3d1dd0ffdb"},
|
||||||
"esbuild": {:hex, :esbuild, "0.10.0", "b0aa3388a1c23e727c5a3e7427c932d89ee791746b0081bbe56103e9ef3d291f", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "468489cda427b974a7cc9f03ace55368a83e1a7be12fba7e30969af78e5f8c70"},
|
"esbuild": {:hex, :esbuild, "0.10.0", "b0aa3388a1c23e727c5a3e7427c932d89ee791746b0081bbe56103e9ef3d291f", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "468489cda427b974a7cc9f03ace55368a83e1a7be12fba7e30969af78e5f8c70"},
|
||||||
"ets": {:hex, :ets, "0.9.0", "79c6a6c205436780486f72d84230c6cba2f8a9920456750ddd1e47389107d5fd", [:mix], [], "hexpm", "2861fdfb04bcaeff370f1a5904eec864f0a56dcfebe5921ea9aadf2a481c822b"},
|
"ets": {:hex, :ets, "0.9.0", "79c6a6c205436780486f72d84230c6cba2f8a9920456750ddd1e47389107d5fd", [:mix], [], "hexpm", "2861fdfb04bcaeff370f1a5904eec864f0a56dcfebe5921ea9aadf2a481c822b"},
|
||||||
"ex_phone_number": {:hex, :ex_phone_number, "0.4.8", "c1c5e6f0673822a2a7b439b43af7d3eb1a5c19ae582b772b8b8d12625dd51ec1", [:mix], [{:sweet_xml, "~> 0.7", [hex: :sweet_xml, repo: "hexpm", optional: false]}], "hexpm", "43e2357c6b8cfe556bcd417f4ce9aaef267a786e31a2938902daaa0d36f69757"},
|
"ex_phone_number": {:hex, :ex_phone_number, "0.4.8", "c1c5e6f0673822a2a7b439b43af7d3eb1a5c19ae582b772b8b8d12625dd51ec1", [:mix], [{:sweet_xml, "~> 0.7", [hex: :sweet_xml, repo: "hexpm", optional: false]}], "hexpm", "43e2357c6b8cfe556bcd417f4ce9aaef267a786e31a2938902daaa0d36f69757"},
|
||||||
"expo": {:hex, :expo, "1.1.0", "f7b9ed7fb5745ebe1eeedf3d6f29226c5dd52897ac67c0f8af62a07e661e5c75", [:mix], [], "hexpm", "fbadf93f4700fb44c331362177bdca9eeb8097e8b0ef525c9cc501cb9917c960"},
|
"expo": {:hex, :expo, "1.1.1", "4202e1d2ca6e2b3b63e02f69cfe0a404f77702b041d02b58597c00992b601db5", [], [], "hexpm", "5fb308b9cb359ae200b7e23d37c76978673aa1b06e2b3075d814ce12c5811640"},
|
||||||
"file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"},
|
"file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"},
|
||||||
"finch": {:hex, :finch, "0.20.0", "5330aefb6b010f424dcbbc4615d914e9e3deae40095e73ab0c1bb0968933cadf", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "2658131a74d051aabfcba936093c903b8e89da9a1b63e430bee62045fa9b2ee2"},
|
"finch": {:hex, :finch, "0.20.0", "5330aefb6b010f424dcbbc4615d914e9e3deae40095e73ab0c1bb0968933cadf", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "2658131a74d051aabfcba936093c903b8e89da9a1b63e430bee62045fa9b2ee2"},
|
||||||
"fine": {:hex, :fine, "0.1.4", "b19a89c1476c7c57afb5f9314aed5960b5bc95d5277de4cb5ee8e1d1616ce379", [:mix], [], "hexpm", "be3324cc454a42d80951cf6023b9954e9ff27c6daa255483b3e8d608670303f5"},
|
"fine": {:hex, :fine, "0.1.4", "b19a89c1476c7c57afb5f9314aed5960b5bc95d5277de4cb5ee8e1d1616ce379", [:mix], [], "hexpm", "be3324cc454a42d80951cf6023b9954e9ff27c6daa255483b3e8d608670303f5"},
|
||||||
|
|
@ -35,14 +35,14 @@
|
||||||
"heroicons": {:git, "https://github.com/tailwindlabs/heroicons.git", "0435d4ca364a608cc75e2f8683d374e55abbae26", [tag: "v2.2.0", sparse: "optimized", depth: 1]},
|
"heroicons": {:git, "https://github.com/tailwindlabs/heroicons.git", "0435d4ca364a608cc75e2f8683d374e55abbae26", [tag: "v2.2.0", sparse: "optimized", depth: 1]},
|
||||||
"hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"},
|
"hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"},
|
||||||
"idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"},
|
"idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"},
|
||||||
"igniter": {:hex, :igniter, "0.6.30", "83a466369ebb8fe009e0823c7bf04314dc545122c2d48f896172fc79df33e99d", [:mix], [{:glob_ex, "~> 0.1.7", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:owl, "~> 0.11", [hex: :owl, repo: "hexpm", optional: false]}, {:phx_new, "~> 1.7", [hex: :phx_new, repo: "hexpm", optional: true]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}, {:rewrite, ">= 1.1.1 and < 2.0.0-0", [hex: :rewrite, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.4", [hex: :sourceror, repo: "hexpm", optional: false]}, {:spitfire, ">= 0.1.3 and < 1.0.0-0", [hex: :spitfire, repo: "hexpm", optional: false]}], "hexpm", "76a14d5b7f850bb03b5243088c3649d54a2e52e34a2aa1104dee23cf50a8bae0"},
|
"igniter": {:hex, :igniter, "0.7.0", "6848714fa5afa14258c82924a57af9364745316241a409435cf39cbe11e3ae80", [:mix], [{:glob_ex, "~> 0.1.7", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:owl, "~> 0.11", [hex: :owl, repo: "hexpm", optional: false]}, {:phx_new, "~> 1.7", [hex: :phx_new, repo: "hexpm", optional: true]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}, {:rewrite, ">= 1.1.1 and < 2.0.0-0", [hex: :rewrite, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.4", [hex: :sourceror, repo: "hexpm", optional: false]}, {:spitfire, ">= 0.1.3 and < 1.0.0-0", [hex: :spitfire, repo: "hexpm", optional: false]}], "hexpm", "1e7254780dbf4b44c9eccd6d86d47aa961efc298d7f520c24acb0258c8e90ba9"},
|
||||||
"iterex": {:hex, :iterex, "0.1.2", "58f9b9b9a22a55cbfc7b5234a9c9c63eaac26d276b3db80936c0e1c60355a5a6", [:mix], [], "hexpm", "2e103b8bcc81757a9af121f6dc0df312c9a17220f302b1193ef720460d03029d"},
|
"iterex": {:hex, :iterex, "0.1.2", "58f9b9b9a22a55cbfc7b5234a9c9c63eaac26d276b3db80936c0e1c60355a5a6", [:mix], [], "hexpm", "2e103b8bcc81757a9af121f6dc0df312c9a17220f302b1193ef720460d03029d"},
|
||||||
"jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"},
|
"jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"},
|
||||||
"joken": {:hex, :joken, "2.6.2", "5daaf82259ca603af4f0b065475099ada1b2b849ff140ccd37f4b6828ca6892a", [:mix], [{:jose, "~> 1.11.10", [hex: :jose, repo: "hexpm", optional: false]}], "hexpm", "5134b5b0a6e37494e46dbf9e4dad53808e5e787904b7c73972651b51cce3d72b"},
|
"joken": {:hex, :joken, "2.6.2", "5daaf82259ca603af4f0b065475099ada1b2b849ff140ccd37f4b6828ca6892a", [:mix], [{:jose, "~> 1.11.10", [hex: :jose, repo: "hexpm", optional: false]}], "hexpm", "5134b5b0a6e37494e46dbf9e4dad53808e5e787904b7c73972651b51cce3d72b"},
|
||||||
"jose": {:hex, :jose, "1.11.10", "a903f5227417bd2a08c8a00a0cbcc458118be84480955e8d251297a425723f83", [:mix, :rebar3], [], "hexpm", "0d6cd36ff8ba174db29148fc112b5842186b68a90ce9fc2b3ec3afe76593e614"},
|
"jose": {:hex, :jose, "1.11.12", "06e62b467b61d3726cbc19e9b5489f7549c37993de846dfb3ee8259f9ed208b3", [], [], "hexpm", "31e92b653e9210b696765cdd885437457de1add2a9011d92f8cf63e4641bab7b"},
|
||||||
"lazy_html": {:hex, :lazy_html, "0.1.8", "677a8642e644eef8de98f3040e2520d42d0f0f8bd6c5cd49db36504e34dffe91", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.9.0", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:fine, "~> 0.1.0", [hex: :fine, repo: "hexpm", optional: false]}], "hexpm", "0d8167d930b704feb94b41414ca7f5779dff9bca7fcf619fcef18de138f08736"},
|
"lazy_html": {:hex, :lazy_html, "0.1.8", "677a8642e644eef8de98f3040e2520d42d0f0f8bd6c5cd49db36504e34dffe91", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.9.0", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:fine, "~> 0.1.0", [hex: :fine, repo: "hexpm", optional: false]}], "hexpm", "0d8167d930b704feb94b41414ca7f5779dff9bca7fcf619fcef18de138f08736"},
|
||||||
"libgraph": {:hex, :libgraph, "0.16.0", "3936f3eca6ef826e08880230f806bfea13193e49bf153f93edcf0239d4fd1d07", [:mix], [], "hexpm", "41ca92240e8a4138c30a7e06466acc709b0cbb795c643e9e17174a178982d6bf"},
|
"libgraph": {:hex, :libgraph, "0.16.0", "3936f3eca6ef826e08880230f806bfea13193e49bf153f93edcf0239d4fd1d07", [:mix], [], "hexpm", "41ca92240e8a4138c30a7e06466acc709b0cbb795c643e9e17174a178982d6bf"},
|
||||||
"live_debugger": {:hex, :live_debugger, "0.4.2", "775c3a570ef3c44d27d261b3c1aae23ef35cac949a57f67b3e7b1aa1fb2707bc", [:mix], [{:igniter, ">= 0.5.40 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.7", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.20.8 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}], "hexpm", "5b24e37985f0424056a322a18dab4a5fb0f4e8ee4e55975985364e0b45d683b9"},
|
"live_debugger": {:hex, :live_debugger, "0.5.0", "95e0f7727d61010f7e9053923fb2a9416904a7533c2dfb36120e7684cba4c0af", [:mix], [{:igniter, ">= 0.5.40 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.7", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.20.8 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}], "hexpm", "73ebe95118d22aa402675f677abd731cb16b136d1b6ae5f4010441fb50753b14"},
|
||||||
"luhn": {:hex, :luhn, "0.3.3", "5aa0c6a32c2db4b9db9f9b883ba8301c1ae169d57199b9e6cb1ba2707bc51d96", [:mix], [], "hexpm", "3e823a913a25aab51352c727f135278d22954874d5f0835be81ed4fec3daf78d"},
|
"luhn": {:hex, :luhn, "0.3.3", "5aa0c6a32c2db4b9db9f9b883ba8301c1ae169d57199b9e6cb1ba2707bc51d96", [:mix], [], "hexpm", "3e823a913a25aab51352c727f135278d22954874d5f0835be81ed4fec3daf78d"},
|
||||||
"mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"},
|
"mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"},
|
||||||
"mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"},
|
"mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"},
|
||||||
|
|
@ -50,41 +50,41 @@
|
||||||
"nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"},
|
"nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"},
|
||||||
"nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"},
|
"nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"},
|
||||||
"owl": {:hex, :owl, "0.13.0", "26010e066d5992774268f3163506972ddac0a7e77bfe57fa42a250f24d6b876e", [:mix], [{:ucwidth, "~> 0.2", [hex: :ucwidth, repo: "hexpm", optional: true]}], "hexpm", "59bf9d11ce37a4db98f57cb68fbfd61593bf419ec4ed302852b6683d3d2f7475"},
|
"owl": {:hex, :owl, "0.13.0", "26010e066d5992774268f3163506972ddac0a7e77bfe57fa42a250f24d6b876e", [:mix], [{:ucwidth, "~> 0.2", [hex: :ucwidth, repo: "hexpm", optional: true]}], "hexpm", "59bf9d11ce37a4db98f57cb68fbfd61593bf419ec4ed302852b6683d3d2f7475"},
|
||||||
"phoenix": {:hex, :phoenix, "1.8.1", "865473a60a979551a4879db79fbfb4503e41cd809e77c85af79716578b6a456d", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "84d77d2b2e77c3c7e7527099bd01ef5c8560cd149c036d6b3a40745f11cd2fb2"},
|
"phoenix": {:hex, :phoenix, "1.8.3", "49ac5e485083cb1495a905e47eb554277bdd9c65ccb4fc5100306b350151aa95", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "36169f95cc2e155b78be93d9590acc3f462f1e5438db06e6248613f27c80caec"},
|
||||||
"phoenix_ecto": {:hex, :phoenix_ecto, "4.6.5", "c4ef322acd15a574a8b1a08eff0ee0a85e73096b53ce1403b6563709f15e1cea", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "26ec3208eef407f31b748cadd044045c6fd485fbff168e35963d2f9dfff28d4b"},
|
"phoenix_ecto": {:hex, :phoenix_ecto, "4.7.0", "75c4b9dfb3efdc42aec2bd5f8bccd978aca0651dbcbc7a3f362ea5d9d43153c6", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "1d75011e4254cb4ddf823e81823a9629559a1be93b4321a6a5f11a5306fbf4cc"},
|
||||||
"phoenix_html": {:hex, :phoenix_html, "4.3.0", "d3577a5df4b6954cd7890c84d955c470b5310bb49647f0a114a6eeecc850f7ad", [:mix], [], "hexpm", "3eaa290a78bab0f075f791a46a981bbe769d94bc776869f4f3063a14f30497ad"},
|
"phoenix_html": {:hex, :phoenix_html, "4.3.0", "d3577a5df4b6954cd7890c84d955c470b5310bb49647f0a114a6eeecc850f7ad", [:mix], [], "hexpm", "3eaa290a78bab0f075f791a46a981bbe769d94bc776869f4f3063a14f30497ad"},
|
||||||
"phoenix_html_helpers": {:hex, :phoenix_html_helpers, "1.0.1", "7eed85c52eff80a179391036931791ee5d2f713d76a81d0d2c6ebafe1e11e5ec", [:mix], [{:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "cffd2385d1fa4f78b04432df69ab8da63dc5cf63e07b713a4dcf36a3740e3090"},
|
"phoenix_html_helpers": {:hex, :phoenix_html_helpers, "1.0.1", "7eed85c52eff80a179391036931791ee5d2f713d76a81d0d2c6ebafe1e11e5ec", [:mix], [{:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "cffd2385d1fa4f78b04432df69ab8da63dc5cf63e07b713a4dcf36a3740e3090"},
|
||||||
"phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.8.7", "405880012cb4b706f26dd1c6349125bfc903fb9e44d1ea668adaf4e04d4884b7", [:mix], [{:ecto, "~> 3.6.2 or ~> 3.7", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_mysql_extras, "~> 0.5", [hex: :ecto_mysql_extras, repo: "hexpm", optional: true]}, {:ecto_psql_extras, "~> 0.7", [hex: :ecto_psql_extras, repo: "hexpm", optional: true]}, {:ecto_sqlite3_extras, "~> 1.1.7 or ~> 1.2.0", [hex: :ecto_sqlite3_extras, repo: "hexpm", optional: true]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.19 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6 or ~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "3a8625cab39ec261d48a13b7468dc619c0ede099601b084e343968309bd4d7d7"},
|
"phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.8.7", "405880012cb4b706f26dd1c6349125bfc903fb9e44d1ea668adaf4e04d4884b7", [:mix], [{:ecto, "~> 3.6.2 or ~> 3.7", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_mysql_extras, "~> 0.5", [hex: :ecto_mysql_extras, repo: "hexpm", optional: true]}, {:ecto_psql_extras, "~> 0.7", [hex: :ecto_psql_extras, repo: "hexpm", optional: true]}, {:ecto_sqlite3_extras, "~> 1.1.7 or ~> 1.2.0", [hex: :ecto_sqlite3_extras, repo: "hexpm", optional: true]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.19 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6 or ~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "3a8625cab39ec261d48a13b7468dc619c0ede099601b084e343968309bd4d7d7"},
|
||||||
"phoenix_live_reload": {:hex, :phoenix_live_reload, "1.6.1", "05df733a09887a005ed0d69a7fc619d376aea2730bf64ce52ac51ce716cc1ef0", [:mix], [{:file_system, "~> 0.2.10 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "74273843d5a6e4fef0bbc17599f33e3ec63f08e69215623a0cd91eea4288e5a0"},
|
"phoenix_live_reload": {:hex, :phoenix_live_reload, "1.6.2", "b18b0773a1ba77f28c52decbb0f10fd1ac4d3ae5b8632399bbf6986e3b665f62", [:mix], [{:file_system, "~> 0.2.10 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "d1f89c18114c50d394721365ffb428cce24f1c13de0467ffa773e2ff4a30d5b9"},
|
||||||
"phoenix_live_view": {:hex, :phoenix_live_view, "1.1.14", "cae84abc4cd00dde4bb200b8516db556704c585c267aff9cd4955ff83cceb86c", [:mix], [{:igniter, ">= 0.6.16 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:lazy_html, "~> 0.1.0", [hex: :lazy_html, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0-rc", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b827980e2bc00fddd8674e3b567519a4e855b5de04bf8607140414f1101e2627"},
|
"phoenix_live_view": {:hex, :phoenix_live_view, "1.1.18", "b5410017b3d4edf261d9c98ebc334e0637d7189457c730720cfc13e206443d43", [:mix], [{:igniter, ">= 0.6.16 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:lazy_html, "~> 0.1.0", [hex: :lazy_html, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0-rc", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "f189b759595feff0420e9a1d544396397f9cf9e2d5a8cb98ba5b6cab01927da0"},
|
||||||
"phoenix_pubsub": {:hex, :phoenix_pubsub, "2.1.3", "3168d78ba41835aecad272d5e8cd51aa87a7ac9eb836eabc42f6e57538e3731d", [:mix], [], "hexpm", "bba06bc1dcfd8cb086759f0edc94a8ba2bc8896d5331a1e2c2902bf8e36ee502"},
|
"phoenix_pubsub": {:hex, :phoenix_pubsub, "2.2.0", "ff3a5616e1bed6804de7773b92cbccfc0b0f473faf1f63d7daf1206c7aeaaa6f", [:mix], [], "hexpm", "adc313a5bf7136039f63cfd9668fde73bba0765e0614cba80c06ac9460ff3e96"},
|
||||||
"phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"},
|
"phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"},
|
||||||
"phoenix_view": {:hex, :phoenix_view, "2.0.4", "b45c9d9cf15b3a1af5fb555c674b525391b6a1fe975f040fb4d913397b31abf4", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}], "hexpm", "4e992022ce14f31fe57335db27a28154afcc94e9983266835bb3040243eb620b"},
|
"phoenix_view": {:hex, :phoenix_view, "2.0.4", "b45c9d9cf15b3a1af5fb555c674b525391b6a1fe975f040fb4d913397b31abf4", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}], "hexpm", "4e992022ce14f31fe57335db27a28154afcc94e9983266835bb3040243eb620b"},
|
||||||
"plug": {:hex, :plug, "1.18.1", "5067f26f7745b7e31bc3368bc1a2b818b9779faa959b49c934c17730efc911cf", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "57a57db70df2b422b564437d2d33cf8d33cd16339c1edb190cd11b1a3a546cc2"},
|
"plug": {:hex, :plug, "1.19.1", "09bac17ae7a001a68ae393658aa23c7e38782be5c5c00c80be82901262c394c0", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "560a0017a8f6d5d30146916862aaf9300b7280063651dd7e532b8be168511e62"},
|
||||||
"plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"},
|
"plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"},
|
||||||
"postgrex": {:hex, :postgrex, "0.21.1", "2c5cc830ec11e7a0067dd4d623c049b3ef807e9507a424985b8dcf921224cd88", [:mix], [{:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "27d8d21c103c3cc68851b533ff99eef353e6a0ff98dc444ea751de43eb48bdac"},
|
"postgrex": {:hex, :postgrex, "0.21.1", "2c5cc830ec11e7a0067dd4d623c049b3ef807e9507a424985b8dcf921224cd88", [:mix], [{:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "27d8d21c103c3cc68851b533ff99eef353e6a0ff98dc444ea751de43eb48bdac"},
|
||||||
"reactor": {:hex, :reactor, "0.17.0", "eb8bdb530dbae824e2d36a8538f8ec4f3aa7c2d1b61b04959fa787c634f88b49", [:mix], [{:igniter, "~> 0.4", [hex: :igniter, repo: "hexpm", optional: true]}, {:iterex, "~> 0.1", [hex: :iterex, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:libgraph, "~> 0.16", [hex: :libgraph, repo: "hexpm", optional: false]}, {:spark, ">= 2.3.3 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.2", [hex: :splode, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.2", [hex: :telemetry, repo: "hexpm", optional: false]}, {:yaml_elixir, "~> 2.11", [hex: :yaml_elixir, repo: "hexpm", optional: false]}, {:ymlr, "~> 5.0", [hex: :ymlr, repo: "hexpm", optional: false]}], "hexpm", "3c3bf71693adbad9117b11ec83cfed7d5851b916ade508ed9718de7ae165bf25"},
|
"reactor": {:hex, :reactor, "0.17.0", "eb8bdb530dbae824e2d36a8538f8ec4f3aa7c2d1b61b04959fa787c634f88b49", [:mix], [{:igniter, "~> 0.4", [hex: :igniter, repo: "hexpm", optional: true]}, {:iterex, "~> 0.1", [hex: :iterex, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:libgraph, "~> 0.16", [hex: :libgraph, repo: "hexpm", optional: false]}, {:spark, ">= 2.3.3 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.2", [hex: :splode, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.2", [hex: :telemetry, repo: "hexpm", optional: false]}, {:yaml_elixir, "~> 2.11", [hex: :yaml_elixir, repo: "hexpm", optional: false]}, {:ymlr, "~> 5.0", [hex: :ymlr, repo: "hexpm", optional: false]}], "hexpm", "3c3bf71693adbad9117b11ec83cfed7d5851b916ade508ed9718de7ae165bf25"},
|
||||||
"req": {:hex, :req, "0.5.15", "662020efb6ea60b9f0e0fac9be88cd7558b53fe51155a2d9899de594f9906ba9", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "a6513a35fad65467893ced9785457e91693352c70b58bbc045b47e5eb2ef0c53"},
|
"req": {:hex, :req, "0.5.16", "99ba6a36b014458e52a8b9a0543bfa752cb0344b2a9d756651db1281d4ba4450", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "974a7a27982b9b791df84e8f6687d21483795882a7840e8309abdbe08bb06f09"},
|
||||||
"rewrite": {:hex, :rewrite, "1.2.0", "80220eb14010e175b67c939397e1a8cdaa2c32db6e2e0a9d5e23e45c0414ce21", [:mix], [{:glob_ex, "~> 0.1", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.0", [hex: :sourceror, repo: "hexpm", optional: false]}, {:text_diff, "~> 0.1", [hex: :text_diff, repo: "hexpm", optional: false]}], "hexpm", "a1cd702bbb9d51613ab21091f04a386d750fc6f4516b81900df082d78b2d8c50"},
|
"rewrite": {:hex, :rewrite, "1.2.0", "80220eb14010e175b67c939397e1a8cdaa2c32db6e2e0a9d5e23e45c0414ce21", [:mix], [{:glob_ex, "~> 0.1", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.0", [hex: :sourceror, repo: "hexpm", optional: false]}, {:text_diff, "~> 0.1", [hex: :text_diff, repo: "hexpm", optional: false]}], "hexpm", "a1cd702bbb9d51613ab21091f04a386d750fc6f4516b81900df082d78b2d8c50"},
|
||||||
"slugify": {:hex, :slugify, "1.3.1", "0d3b8b7e5c1eeaa960e44dce94382bee34a39b3ea239293e457a9c5b47cc6fd3", [:mix], [], "hexpm", "cb090bbeb056b312da3125e681d98933a360a70d327820e4b7f91645c4d8be76"},
|
"slugify": {:hex, :slugify, "1.3.1", "0d3b8b7e5c1eeaa960e44dce94382bee34a39b3ea239293e457a9c5b47cc6fd3", [:mix], [], "hexpm", "cb090bbeb056b312da3125e681d98933a360a70d327820e4b7f91645c4d8be76"},
|
||||||
"sobelow": {:hex, :sobelow, "0.14.1", "2f81e8632f15574cba2402bcddff5497b413c01e6f094bc0ab94e83c2f74db81", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "8fac9a2bd90fdc4b15d6fca6e1608efb7f7c600fa75800813b794ee9364c87f2"},
|
"sobelow": {:hex, :sobelow, "0.14.1", "2f81e8632f15574cba2402bcddff5497b413c01e6f094bc0ab94e83c2f74db81", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "8fac9a2bd90fdc4b15d6fca6e1608efb7f7c600fa75800813b794ee9364c87f2"},
|
||||||
"sourceror": {:hex, :sourceror, "1.10.0", "38397dedbbc286966ec48c7af13e228b171332be1ad731974438c77791945ce9", [:mix], [], "hexpm", "29dbdfc92e04569c9d8e6efdc422fc1d815f4bd0055dc7c51b8800fb75c4b3f1"},
|
"sourceror": {:hex, :sourceror, "1.10.0", "38397dedbbc286966ec48c7af13e228b171332be1ad731974438c77791945ce9", [:mix], [], "hexpm", "29dbdfc92e04569c9d8e6efdc422fc1d815f4bd0055dc7c51b8800fb75c4b3f1"},
|
||||||
"spark": {:hex, :spark, "2.3.5", "f30d30ecc3b4ab9b932d9aada66af7677fc1f297a2c349b0bcec3eafb9f996e8", [:mix], [{:igniter, ">= 0.3.64 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: true]}, {:sourceror, "~> 1.2", [hex: :sourceror, repo: "hexpm", optional: true]}], "hexpm", "0e9d339704d5d148f77f2b2fef3bcfc873a9e9bb4224fcf289c545d65827202f"},
|
"spark": {:hex, :spark, "2.3.14", "a08420d08e6e0e49d740aed3e160f1cb894ba8f6b3f5e6c63253e9df1995265c", [:mix], [{:igniter, ">= 0.3.64 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: true]}, {:sourceror, "~> 1.2", [hex: :sourceror, repo: "hexpm", optional: true]}], "hexpm", "af50c4ea5dd67eba822247f1c98e1d4e598cb7f6c28ccf5d002f0e0718096f4f"},
|
||||||
"spitfire": {:hex, :spitfire, "0.2.1", "29e154873f05444669c7453d3d931820822cbca5170e88f0f8faa1de74a79b47", [:mix], [], "hexpm", "6eeed75054a38341b2e1814d41bb0a250564092358de2669fdb57ff88141d91b"},
|
"spitfire": {:hex, :spitfire, "0.2.1", "29e154873f05444669c7453d3d931820822cbca5170e88f0f8faa1de74a79b47", [:mix], [], "hexpm", "6eeed75054a38341b2e1814d41bb0a250564092358de2669fdb57ff88141d91b"},
|
||||||
"splode": {:hex, :splode, "0.2.9", "3a2776e187c82f42f5226b33b1220ccbff74f4bcc523dd4039c804caaa3ffdc7", [:mix], [], "hexpm", "8002b00c6e24f8bd1bcced3fbaa5c33346048047bb7e13d2f3ad428babbd95c3"},
|
"splode": {:hex, :splode, "0.2.9", "3a2776e187c82f42f5226b33b1220ccbff74f4bcc523dd4039c804caaa3ffdc7", [:mix], [], "hexpm", "8002b00c6e24f8bd1bcced3fbaa5c33346048047bb7e13d2f3ad428babbd95c3"},
|
||||||
"stream_data": {:hex, :stream_data, "1.2.0", "58dd3f9e88afe27dc38bef26fce0c84a9e7a96772b2925c7b32cd2435697a52b", [:mix], [], "hexpm", "eb5c546ee3466920314643edf68943a5b14b32d1da9fe01698dc92b73f89a9ed"},
|
"stream_data": {:hex, :stream_data, "1.2.0", "58dd3f9e88afe27dc38bef26fce0c84a9e7a96772b2925c7b32cd2435697a52b", [:mix], [], "hexpm", "eb5c546ee3466920314643edf68943a5b14b32d1da9fe01698dc92b73f89a9ed"},
|
||||||
"sweet_xml": {:hex, :sweet_xml, "0.7.5", "803a563113981aaac202a1dbd39771562d0ad31004ddbfc9b5090bdcd5605277", [:mix], [], "hexpm", "193b28a9b12891cae351d81a0cead165ffe67df1b73fe5866d10629f4faefb12"},
|
"sweet_xml": {:hex, :sweet_xml, "0.7.5", "803a563113981aaac202a1dbd39771562d0ad31004ddbfc9b5090bdcd5605277", [:mix], [], "hexpm", "193b28a9b12891cae351d81a0cead165ffe67df1b73fe5866d10629f4faefb12"},
|
||||||
"swoosh": {:hex, :swoosh, "1.19.8", "0576f2ea96d1bb3a6e02cc9f79cbd7d497babc49a353eef8dce1a1f9f82d7915", [:mix], [{:bandit, ">= 1.0.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:idna, "~> 6.0", [hex: :idna, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mua, "~> 0.2.3", [hex: :mua, repo: "hexpm", optional: true]}, {:multipart, "~> 0.4", [hex: :multipart, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.5.10 or ~> 0.6 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "d7503c2daf0f9899afd8eba9923eeddef4b62e70816e1d3b6766e4d6c60e94ad"},
|
"swoosh": {:hex, :swoosh, "1.19.9", "4eb2c471b8cf06adbdcaa1d57a0ad53c0ed9348ce8586a06cc491f9f0dbcb553", [:mix], [{:bandit, ">= 1.0.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:cowboy, "~> 1.1 or ~> 2.4", [hex: :cowboy, repo: "hexpm", optional: true]}, {:ex_aws, "~> 2.1", [hex: :ex_aws, repo: "hexpm", optional: true]}, {:finch, "~> 0.6", [hex: :finch, repo: "hexpm", optional: true]}, {:gen_smtp, "~> 0.13 or ~> 1.0", [hex: :gen_smtp, repo: "hexpm", optional: true]}, {:hackney, "~> 1.9", [hex: :hackney, repo: "hexpm", optional: true]}, {:idna, "~> 6.0", [hex: :idna, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mail, "~> 0.2", [hex: :mail, repo: "hexpm", optional: true]}, {:mime, "~> 1.1 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mua, "~> 0.2.3", [hex: :mua, repo: "hexpm", optional: true]}, {:multipart, "~> 0.4", [hex: :multipart, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: true]}, {:plug_cowboy, ">= 1.0.0", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:req, "~> 0.5.10 or ~> 0.6 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "516898263a64925c31723c56bc7999a26e97b04e869707f681f4c9bca7ee1688"},
|
||||||
"tailwind": {:hex, :tailwind, "0.4.1", "e7bcc222fe96a1e55f948e76d13dd84a1a7653fb051d2a167135db3b4b08d3e9", [:mix], [], "hexpm", "6249d4f9819052911120dbdbe9e532e6bd64ea23476056adb7f730aa25c220d1"},
|
"tailwind": {:hex, :tailwind, "0.4.1", "e7bcc222fe96a1e55f948e76d13dd84a1a7653fb051d2a167135db3b4b08d3e9", [:mix], [], "hexpm", "6249d4f9819052911120dbdbe9e532e6bd64ea23476056adb7f730aa25c220d1"},
|
||||||
"telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"},
|
"telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"},
|
||||||
"telemetry_metrics": {:hex, :telemetry_metrics, "1.1.0", "5bd5f3b5637e0abea0426b947e3ce5dd304f8b3bc6617039e2b5a008adc02f8f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e7b79e8ddfde70adb6db8a6623d1778ec66401f366e9a8f5dd0955c56bc8ce67"},
|
"telemetry_metrics": {:hex, :telemetry_metrics, "1.1.0", "5bd5f3b5637e0abea0426b947e3ce5dd304f8b3bc6617039e2b5a008adc02f8f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e7b79e8ddfde70adb6db8a6623d1778ec66401f366e9a8f5dd0955c56bc8ce67"},
|
||||||
"telemetry_poller": {:hex, :telemetry_poller, "1.3.0", "d5c46420126b5ac2d72bc6580fb4f537d35e851cc0f8dbd571acf6d6e10f5ec7", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "51f18bed7128544a50f75897db9974436ea9bfba560420b646af27a9a9b35211"},
|
"telemetry_poller": {:hex, :telemetry_poller, "1.3.0", "d5c46420126b5ac2d72bc6580fb4f537d35e851cc0f8dbd571acf6d6e10f5ec7", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "51f18bed7128544a50f75897db9974436ea9bfba560420b646af27a9a9b35211"},
|
||||||
"text_diff": {:hex, :text_diff, "0.1.0", "1caf3175e11a53a9a139bc9339bd607c47b9e376b073d4571c031913317fecaa", [:mix], [], "hexpm", "d1ffaaecab338e49357b6daa82e435f877e0649041ace7755583a0ea3362dbd7"},
|
"text_diff": {:hex, :text_diff, "0.1.0", "1caf3175e11a53a9a139bc9339bd607c47b9e376b073d4571c031913317fecaa", [:mix], [], "hexpm", "d1ffaaecab338e49357b6daa82e435f877e0649041ace7755583a0ea3362dbd7"},
|
||||||
"thousand_island": {:hex, :thousand_island, "1.4.1", "8df065e627407e281f7935da5ad0f3842d10eb721afa92e760b720d71e2e37aa", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "204a8640e5d2818589b87286ae66160978628d7edf6095181cbe0440765fb6c1"},
|
"thousand_island": {:hex, :thousand_island, "1.4.2", "735fa783005d1703359bbd2d3a5a3a398075ba4456e5afe3c5b7cf4666303d36", [], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "1c7637f16558fc1c35746d5ee0e83b18b8e59e18d28affd1f2fa1645f8bc7473"},
|
||||||
"tidewave": {:hex, :tidewave, "0.5.0", "8f278d7eb2d0af36ae6d4f73a5872bd066815bd57b57401125187ba901f095a4", [:mix], [{:circular_buffer, "~> 0.4 or ~> 1.0", [hex: :circular_buffer, repo: "hexpm", optional: false]}, {:igniter, "~> 0.6", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix_live_reload, ">= 1.6.1", [hex: :phoenix_live_reload, repo: "hexpm", optional: true]}, {:plug, "~> 1.17", [hex: :plug, repo: "hexpm", optional: false]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}], "hexpm", "9a1eb5d2f12ff4912328dfbfe652c27fded462c6ed6fd11814ee28d3e9d016b4"},
|
"tidewave": {:hex, :tidewave, "0.5.2", "f549acffe9daeed8b6b547c232c60de987770da7f827f9b3300140dfc465b102", [:mix], [{:circular_buffer, "~> 0.4 or ~> 1.0", [hex: :circular_buffer, repo: "hexpm", optional: false]}, {:igniter, "~> 0.6", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix_live_reload, ">= 1.6.1", [hex: :phoenix_live_reload, repo: "hexpm", optional: true]}, {:plug, "~> 1.17", [hex: :plug, repo: "hexpm", optional: false]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}], "hexpm", "34ab3ffee7e402f05cd1eae68d0e77ed0e0d1925677971ef83634247553e8afd"},
|
||||||
"unicode_util_compat": {:hex, :unicode_util_compat, "0.7.1", "a48703a25c170eedadca83b11e88985af08d35f37c6f664d6dcfb106a97782fc", [:rebar3], [], "hexpm", "b3a917854ce3ae233619744ad1e0102e05673136776fb2fa76234f3e03b23642"},
|
"unicode_util_compat": {:hex, :unicode_util_compat, "0.7.1", "a48703a25c170eedadca83b11e88985af08d35f37c6f664d6dcfb106a97782fc", [:rebar3], [], "hexpm", "b3a917854ce3ae233619744ad1e0102e05673136776fb2fa76234f3e03b23642"},
|
||||||
"websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"},
|
"websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"},
|
||||||
"websock_adapter": {:hex, :websock_adapter, "0.5.8", "3b97dc94e407e2d1fc666b2fb9acf6be81a1798a2602294aac000260a7c4a47d", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "315b9a1865552212b5f35140ad194e67ce31af45bcee443d4ecb96b5fd3f3782"},
|
"websock_adapter": {:hex, :websock_adapter, "0.5.9", "43dc3ba6d89ef5dec5b1d0a39698436a1e856d000d84bf31a3149862b01a287f", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "5534d5c9adad3c18a0f58a9371220d75a803bf0b9a3d87e6fe072faaeed76a08"},
|
||||||
"yamerl": {:hex, :yamerl, "0.10.0", "4ff81fee2f1f6a46f1700c0d880b24d193ddb74bd14ef42cb0bcf46e81ef2f8e", [:rebar3], [], "hexpm", "346adb2963f1051dc837a2364e4acf6eb7d80097c0f53cbdc3046ec8ec4b4e6e"},
|
"yamerl": {:hex, :yamerl, "0.10.0", "4ff81fee2f1f6a46f1700c0d880b24d193ddb74bd14ef42cb0bcf46e81ef2f8e", [:rebar3], [], "hexpm", "346adb2963f1051dc837a2364e4acf6eb7d80097c0f53cbdc3046ec8ec4b4e6e"},
|
||||||
"yaml_elixir": {:hex, :yaml_elixir, "2.12.0", "30343ff5018637a64b1b7de1ed2a3ca03bc641410c1f311a4dbdc1ffbbf449c7", [:mix], [{:yamerl, "~> 0.10", [hex: :yamerl, repo: "hexpm", optional: false]}], "hexpm", "ca6bacae7bac917a7155dca0ab6149088aa7bc800c94d0fe18c5238f53b313c6"},
|
"yaml_elixir": {:hex, :yaml_elixir, "2.12.0", "30343ff5018637a64b1b7de1ed2a3ca03bc641410c1f311a4dbdc1ffbbf449c7", [:mix], [{:yamerl, "~> 0.10", [hex: :yamerl, repo: "hexpm", optional: false]}], "hexpm", "ca6bacae7bac917a7155dca0ab6149088aa7bc800c94d0fe18c5238f53b313c6"},
|
||||||
"ymlr": {:hex, :ymlr, "5.1.4", "b924d61e1fc1ec371cde6ab3ccd9311110b1e052fc5c2460fb322e8380e7712a", [:mix], [], "hexpm", "75f16cf0709fbd911b30311a0359a7aa4b5476346c01882addefd5f2b1cfaa51"},
|
"ymlr": {:hex, :ymlr, "5.1.4", "b924d61e1fc1ec371cde6ab3ccd9311110b1e052fc5c2460fb322e8380e7712a", [:mix], [], "hexpm", "75f16cf0709fbd911b30311a0359a7aa4b5476346c01882addefd5f2b1cfaa51"},
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,7 @@ msgstr ""
|
||||||
msgid "Need an account?"
|
msgid "Need an account?"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex:268
|
#: lib/mv_web/live/auth/link_oidc_account_live.ex
|
||||||
#, elixir-autogen
|
#, elixir-autogen
|
||||||
msgid "Password"
|
msgid "Password"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
@ -65,78 +65,77 @@ msgstr ""
|
||||||
msgid "Your password has successfully been reset"
|
msgid "Your password has successfully been reset"
|
||||||
msgstr ""
|
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
|
#, elixir-autogen, elixir-format
|
||||||
msgid "An account with email %{email} already exists. Please enter your password to link your OIDC account."
|
msgid "An account with email %{email} already exists. Please enter your password to link your OIDC account."
|
||||||
msgstr ""
|
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
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Cancel"
|
msgid "Cancel"
|
||||||
msgstr ""
|
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
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Incorrect password. Please try again."
|
msgid "Incorrect password. Please try again."
|
||||||
msgstr ""
|
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
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Invalid session. Please try again."
|
msgid "Invalid session. Please try again."
|
||||||
msgstr ""
|
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
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Link Account"
|
msgid "Link Account"
|
||||||
msgstr ""
|
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
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Link OIDC Account"
|
msgid "Link OIDC Account"
|
||||||
msgstr ""
|
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
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Linking..."
|
msgid "Linking..."
|
||||||
msgstr ""
|
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
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Session expired. Please try again."
|
msgid "Session expired. Please try again."
|
||||||
msgstr ""
|
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
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Your OIDC account has been successfully linked! Redirecting to complete sign-in..."
|
msgid "Your OIDC account has been successfully linked! Redirecting to complete sign-in..."
|
||||||
msgstr ""
|
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
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Account activated! Redirecting to complete sign-in..."
|
msgid "Account activated! Redirecting to complete sign-in..."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex:119
|
#: lib/mv_web/live/auth/link_oidc_account_live.ex
|
||||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex:123
|
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Failed to link account. Please try again or contact support."
|
msgid "Failed to link account. Please try again or contact support."
|
||||||
msgstr ""
|
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
|
#, 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."
|
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 ""
|
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
|
#, elixir-autogen, elixir-format
|
||||||
msgid "This OIDC account is already linked to another user. Please contact support."
|
msgid "This OIDC account is already linked to another user. Please contact support."
|
||||||
msgstr ""
|
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
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Language selection"
|
msgid "Language selection"
|
||||||
msgstr ""
|
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
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Select language"
|
msgid "Select language"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,7 @@ msgstr "Falls diese*r Benutzer*in bekannt ist, wird jetzt eine Email mit einer A
|
||||||
msgid "Need an account?"
|
msgid "Need an account?"
|
||||||
msgstr "Konto anlegen?"
|
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
|
#, elixir-autogen
|
||||||
msgid "Password"
|
msgid "Password"
|
||||||
msgstr "Passwort"
|
msgstr "Passwort"
|
||||||
|
|
@ -64,78 +64,77 @@ msgstr "Anmelden..."
|
||||||
msgid "Your password has successfully been reset"
|
msgid "Your password has successfully been reset"
|
||||||
msgstr "Das Passwort wurde erfolgreich zurückgesetzt"
|
msgstr "Das Passwort wurde erfolgreich zurückgesetzt"
|
||||||
|
|
||||||
#: 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
|
#, elixir-autogen, elixir-format
|
||||||
msgid "An account with email %{email} already exists. Please enter your password to link your OIDC account."
|
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."
|
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
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Cancel"
|
msgid "Cancel"
|
||||||
msgstr "Abbrechen"
|
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
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Incorrect password. Please try again."
|
msgid "Incorrect password. Please try again."
|
||||||
msgstr "Falsches Passwort. Bitte versuchen Sie es erneut."
|
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
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Invalid session. Please try again."
|
msgid "Invalid session. Please try again."
|
||||||
msgstr "Ungültige Sitzung. Bitte versuchen Sie es erneut."
|
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
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Link Account"
|
msgid "Link Account"
|
||||||
msgstr "Konto verknüpfen"
|
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
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Link OIDC Account"
|
msgid "Link OIDC Account"
|
||||||
msgstr "OIDC-Konto verknüpfen"
|
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
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Linking..."
|
msgid "Linking..."
|
||||||
msgstr "Verknüpfen..."
|
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
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Session expired. Please try again."
|
msgid "Session expired. Please try again."
|
||||||
msgstr "Sitzung abgelaufen. Bitte versuchen Sie es erneut."
|
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
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Your OIDC account has been successfully linked! Redirecting to complete sign-in..."
|
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..."
|
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
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Account activated! Redirecting to complete sign-in..."
|
msgid "Account activated! Redirecting to complete sign-in..."
|
||||||
msgstr "Konto aktiviert! Sie werden zur Anmeldung weitergeleitet..."
|
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
|
||||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex:123
|
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Failed to link account. Please try again or contact support."
|
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."
|
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
|
#, 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."
|
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."
|
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
|
#, elixir-autogen, elixir-format
|
||||||
msgid "This OIDC account is already linked to another user. Please contact support."
|
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."
|
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
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Language selection"
|
msgid "Language selection"
|
||||||
msgstr "Sprachauswahl"
|
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
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Select language"
|
msgid "Select language"
|
||||||
msgstr "Sprache auswählen"
|
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?"
|
msgid "Need an account?"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex:268
|
#: lib/mv_web/live/auth/link_oidc_account_live.ex
|
||||||
#, elixir-autogen
|
#, elixir-autogen
|
||||||
msgid "Password"
|
msgid "Password"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
@ -61,78 +61,77 @@ msgstr ""
|
||||||
msgid "Your password has successfully been reset"
|
msgid "Your password has successfully been reset"
|
||||||
msgstr ""
|
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
|
#, elixir-autogen, elixir-format
|
||||||
msgid "An account with email %{email} already exists. Please enter your password to link your OIDC account."
|
msgid "An account with email %{email} already exists. Please enter your password to link your OIDC account."
|
||||||
msgstr ""
|
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
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Cancel"
|
msgid "Cancel"
|
||||||
msgstr ""
|
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
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Incorrect password. Please try again."
|
msgid "Incorrect password. Please try again."
|
||||||
msgstr ""
|
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
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Invalid session. Please try again."
|
msgid "Invalid session. Please try again."
|
||||||
msgstr ""
|
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
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Link Account"
|
msgid "Link Account"
|
||||||
msgstr ""
|
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
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Link OIDC Account"
|
msgid "Link OIDC Account"
|
||||||
msgstr ""
|
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
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Linking..."
|
msgid "Linking..."
|
||||||
msgstr ""
|
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
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Session expired. Please try again."
|
msgid "Session expired. Please try again."
|
||||||
msgstr ""
|
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
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Your OIDC account has been successfully linked! Redirecting to complete sign-in..."
|
msgid "Your OIDC account has been successfully linked! Redirecting to complete sign-in..."
|
||||||
msgstr ""
|
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
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Account activated! Redirecting to complete sign-in..."
|
msgid "Account activated! Redirecting to complete sign-in..."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex:119
|
#: lib/mv_web/live/auth/link_oidc_account_live.ex
|
||||||
#: lib/mv_web/live/auth/link_oidc_account_live.ex:123
|
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Failed to link account. Please try again or contact support."
|
msgid "Failed to link account. Please try again or contact support."
|
||||||
msgstr ""
|
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
|
#, 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."
|
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 ""
|
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
|
#, elixir-autogen, elixir-format
|
||||||
msgid "This OIDC account is already linked to another user. Please contact support."
|
msgid "This OIDC account is already linked to another user. Please contact support."
|
||||||
msgstr ""
|
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
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Language selection"
|
msgid "Language selection"
|
||||||
msgstr ""
|
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
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Select language"
|
msgid "Select language"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,259 @@
|
||||||
|
defmodule Mv.Repo.Migrations.AddCustomFieldValuesToSearchVector do
|
||||||
|
@moduledoc """
|
||||||
|
Extends the search_vector in members table to include custom_field_values.
|
||||||
|
|
||||||
|
This migration:
|
||||||
|
1. Updates the members_search_vector_trigger() function to include custom field values
|
||||||
|
2. Creates a trigger function to update member search_vector when custom_field_values change
|
||||||
|
3. Creates a trigger on custom_field_values table
|
||||||
|
4. Updates existing search_vector values for all members
|
||||||
|
"""
|
||||||
|
|
||||||
|
use Ecto.Migration
|
||||||
|
|
||||||
|
def up do
|
||||||
|
# Update the main trigger function to include custom_field_values
|
||||||
|
execute("""
|
||||||
|
CREATE OR REPLACE FUNCTION members_search_vector_trigger() RETURNS trigger AS $$
|
||||||
|
DECLARE
|
||||||
|
custom_values_text text;
|
||||||
|
BEGIN
|
||||||
|
-- Aggregate all custom field values for this member
|
||||||
|
-- Support both formats: _union_type/_union_value (Ash format) and type/value (legacy)
|
||||||
|
-- ->> operator always returns TEXT directly (no need for -> + ::text fallback)
|
||||||
|
SELECT string_agg(
|
||||||
|
CASE
|
||||||
|
WHEN value ? '_union_value' THEN value->>'_union_value'
|
||||||
|
WHEN value ? 'value' THEN value->>'value'
|
||||||
|
ELSE ''
|
||||||
|
END,
|
||||||
|
' '
|
||||||
|
)
|
||||||
|
INTO custom_values_text
|
||||||
|
FROM custom_field_values
|
||||||
|
WHERE member_id = NEW.id AND value IS NOT NULL;
|
||||||
|
|
||||||
|
-- Build search_vector with member fields and custom field values
|
||||||
|
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') ||
|
||||||
|
setweight(to_tsvector('simple', coalesce(custom_values_text, '')), 'C');
|
||||||
|
RETURN NEW;
|
||||||
|
END
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
""")
|
||||||
|
|
||||||
|
# Create trigger function to update member search_vector when custom_field_values change
|
||||||
|
# Optimized:
|
||||||
|
# 1. Only fetch required fields instead of full member record to reduce overhead
|
||||||
|
# 2. Skip re-aggregation on UPDATE if value hasn't actually changed
|
||||||
|
execute("""
|
||||||
|
CREATE FUNCTION update_member_search_vector_from_custom_field_value() RETURNS trigger AS $$
|
||||||
|
DECLARE
|
||||||
|
member_id_val uuid;
|
||||||
|
member_first_name text;
|
||||||
|
member_last_name text;
|
||||||
|
member_email text;
|
||||||
|
member_phone_number text;
|
||||||
|
member_join_date date;
|
||||||
|
member_exit_date date;
|
||||||
|
member_notes text;
|
||||||
|
member_city text;
|
||||||
|
member_street text;
|
||||||
|
member_house_number text;
|
||||||
|
member_postal_code text;
|
||||||
|
custom_values_text text;
|
||||||
|
old_value_text text;
|
||||||
|
new_value_text text;
|
||||||
|
BEGIN
|
||||||
|
-- Get member ID from trigger context
|
||||||
|
member_id_val := COALESCE(NEW.member_id, OLD.member_id);
|
||||||
|
|
||||||
|
-- Optimization: For UPDATE operations, check if value actually changed
|
||||||
|
-- If value hasn't changed, we can skip the expensive re-aggregation
|
||||||
|
IF TG_OP = 'UPDATE' THEN
|
||||||
|
-- Extract OLD value for comparison (handle both JSONB formats)
|
||||||
|
-- ->> operator always returns TEXT directly
|
||||||
|
old_value_text := COALESCE(
|
||||||
|
NULLIF(OLD.value->>'_union_value', ''),
|
||||||
|
NULLIF(OLD.value->>'value', ''),
|
||||||
|
''
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Extract NEW value for comparison (handle both JSONB formats)
|
||||||
|
new_value_text := COALESCE(
|
||||||
|
NULLIF(NEW.value->>'_union_value', ''),
|
||||||
|
NULLIF(NEW.value->>'value', ''),
|
||||||
|
''
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Check if value, member_id, or custom_field_id actually changed
|
||||||
|
-- If nothing changed, skip expensive re-aggregation
|
||||||
|
IF (old_value_text IS NOT DISTINCT FROM new_value_text) AND
|
||||||
|
(OLD.member_id IS NOT DISTINCT FROM NEW.member_id) AND
|
||||||
|
(OLD.custom_field_id IS NOT DISTINCT FROM NEW.custom_field_id) THEN
|
||||||
|
RETURN COALESCE(NEW, OLD);
|
||||||
|
END IF;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Fetch only required fields instead of full record (performance optimization)
|
||||||
|
SELECT
|
||||||
|
first_name,
|
||||||
|
last_name,
|
||||||
|
email,
|
||||||
|
phone_number,
|
||||||
|
join_date,
|
||||||
|
exit_date,
|
||||||
|
notes,
|
||||||
|
city,
|
||||||
|
street,
|
||||||
|
house_number,
|
||||||
|
postal_code
|
||||||
|
INTO
|
||||||
|
member_first_name,
|
||||||
|
member_last_name,
|
||||||
|
member_email,
|
||||||
|
member_phone_number,
|
||||||
|
member_join_date,
|
||||||
|
member_exit_date,
|
||||||
|
member_notes,
|
||||||
|
member_city,
|
||||||
|
member_street,
|
||||||
|
member_house_number,
|
||||||
|
member_postal_code
|
||||||
|
FROM members
|
||||||
|
WHERE id = member_id_val;
|
||||||
|
|
||||||
|
-- Aggregate all custom field values for this member
|
||||||
|
-- Support both formats: _union_type/_union_value (Ash format) and type/value (legacy)
|
||||||
|
-- ->> operator always returns TEXT directly
|
||||||
|
SELECT string_agg(
|
||||||
|
CASE
|
||||||
|
WHEN value ? '_union_value' THEN value->>'_union_value'
|
||||||
|
WHEN value ? 'value' THEN value->>'value'
|
||||||
|
ELSE ''
|
||||||
|
END,
|
||||||
|
' '
|
||||||
|
)
|
||||||
|
INTO custom_values_text
|
||||||
|
FROM custom_field_values
|
||||||
|
WHERE member_id = member_id_val AND value IS NOT NULL;
|
||||||
|
|
||||||
|
-- Update the search_vector for the affected member
|
||||||
|
UPDATE members
|
||||||
|
SET search_vector =
|
||||||
|
setweight(to_tsvector('simple', coalesce(member_first_name, '')), 'A') ||
|
||||||
|
setweight(to_tsvector('simple', coalesce(member_last_name, '')), 'A') ||
|
||||||
|
setweight(to_tsvector('simple', coalesce(member_email, '')), 'B') ||
|
||||||
|
setweight(to_tsvector('simple', coalesce(member_phone_number, '')), 'C') ||
|
||||||
|
setweight(to_tsvector('simple', coalesce(member_join_date::text, '')), 'D') ||
|
||||||
|
setweight(to_tsvector('simple', coalesce(member_exit_date::text, '')), 'D') ||
|
||||||
|
setweight(to_tsvector('simple', coalesce(member_notes, '')), 'B') ||
|
||||||
|
setweight(to_tsvector('simple', coalesce(member_city, '')), 'C') ||
|
||||||
|
setweight(to_tsvector('simple', coalesce(member_street, '')), 'C') ||
|
||||||
|
setweight(to_tsvector('simple', coalesce(member_house_number::text, '')), 'C') ||
|
||||||
|
setweight(to_tsvector('simple', coalesce(member_postal_code::text, '')), 'C') ||
|
||||||
|
setweight(to_tsvector('simple', coalesce(custom_values_text, '')), 'C')
|
||||||
|
WHERE id = member_id_val;
|
||||||
|
|
||||||
|
RETURN COALESCE(NEW, OLD);
|
||||||
|
END
|
||||||
|
$$ LANGUAGE plpgsql;
|
||||||
|
""")
|
||||||
|
|
||||||
|
# Create trigger on custom_field_values table
|
||||||
|
execute("""
|
||||||
|
CREATE TRIGGER update_member_search_vector_on_custom_field_value_change
|
||||||
|
AFTER INSERT OR UPDATE OR DELETE ON custom_field_values
|
||||||
|
FOR EACH ROW
|
||||||
|
EXECUTE FUNCTION update_member_search_vector_from_custom_field_value()
|
||||||
|
""")
|
||||||
|
|
||||||
|
# Update existing search_vector values for all members
|
||||||
|
execute("""
|
||||||
|
UPDATE members m
|
||||||
|
SET search_vector =
|
||||||
|
setweight(to_tsvector('simple', coalesce(m.first_name, '')), 'A') ||
|
||||||
|
setweight(to_tsvector('simple', coalesce(m.last_name, '')), 'A') ||
|
||||||
|
setweight(to_tsvector('simple', coalesce(m.email, '')), 'B') ||
|
||||||
|
setweight(to_tsvector('simple', coalesce(m.phone_number, '')), 'C') ||
|
||||||
|
setweight(to_tsvector('simple', coalesce(m.join_date::text, '')), 'D') ||
|
||||||
|
setweight(to_tsvector('simple', coalesce(m.exit_date::text, '')), 'D') ||
|
||||||
|
setweight(to_tsvector('simple', coalesce(m.notes, '')), 'B') ||
|
||||||
|
setweight(to_tsvector('simple', coalesce(m.city, '')), 'C') ||
|
||||||
|
setweight(to_tsvector('simple', coalesce(m.street, '')), 'C') ||
|
||||||
|
setweight(to_tsvector('simple', coalesce(m.house_number::text, '')), 'C') ||
|
||||||
|
setweight(to_tsvector('simple', coalesce(m.postal_code::text, '')), 'C') ||
|
||||||
|
setweight(to_tsvector('simple', coalesce(
|
||||||
|
(SELECT string_agg(
|
||||||
|
CASE
|
||||||
|
WHEN value ? '_union_value' THEN value->>'_union_value'
|
||||||
|
WHEN value ? 'value' THEN value->>'value'
|
||||||
|
ELSE ''
|
||||||
|
END,
|
||||||
|
' '
|
||||||
|
)
|
||||||
|
FROM custom_field_values
|
||||||
|
WHERE member_id = m.id AND value IS NOT NULL),
|
||||||
|
''
|
||||||
|
)), 'C')
|
||||||
|
""")
|
||||||
|
end
|
||||||
|
|
||||||
|
def down do
|
||||||
|
# Drop trigger on custom_field_values
|
||||||
|
execute(
|
||||||
|
"DROP TRIGGER IF EXISTS update_member_search_vector_on_custom_field_value_change ON custom_field_values"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Drop trigger function
|
||||||
|
execute("DROP FUNCTION IF EXISTS update_member_search_vector_from_custom_field_value()")
|
||||||
|
|
||||||
|
# Restore original trigger function without custom_field_values
|
||||||
|
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;
|
||||||
|
""")
|
||||||
|
|
||||||
|
# Update existing search_vector values to remove custom_field_values
|
||||||
|
execute("""
|
||||||
|
UPDATE members m
|
||||||
|
SET search_vector =
|
||||||
|
setweight(to_tsvector('simple', coalesce(m.first_name, '')), 'A') ||
|
||||||
|
setweight(to_tsvector('simple', coalesce(m.last_name, '')), 'A') ||
|
||||||
|
setweight(to_tsvector('simple', coalesce(m.email, '')), 'B') ||
|
||||||
|
setweight(to_tsvector('simple', coalesce(m.phone_number, '')), 'C') ||
|
||||||
|
setweight(to_tsvector('simple', coalesce(m.join_date::text, '')), 'D') ||
|
||||||
|
setweight(to_tsvector('simple', coalesce(m.exit_date::text, '')), 'D') ||
|
||||||
|
setweight(to_tsvector('simple', coalesce(m.notes, '')), 'B') ||
|
||||||
|
setweight(to_tsvector('simple', coalesce(m.city, '')), 'C') ||
|
||||||
|
setweight(to_tsvector('simple', coalesce(m.street, '')), 'C') ||
|
||||||
|
setweight(to_tsvector('simple', coalesce(m.house_number::text, '')), 'C') ||
|
||||||
|
setweight(to_tsvector('simple', coalesce(m.postal_code::text, '')), 'C')
|
||||||
|
""")
|
||||||
|
end
|
||||||
|
end
|
||||||
202
priv/resource_snapshots/repo/members/20251204123714.json
Normal file
202
priv/resource_snapshots/repo/members/20251204123714.json
Normal file
|
|
@ -0,0 +1,202 @@
|
||||||
|
{
|
||||||
|
"attributes": [
|
||||||
|
{
|
||||||
|
"allow_nil?": false,
|
||||||
|
"default": "fragment(\"uuid_generate_v7()\")",
|
||||||
|
"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": "first_name",
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"allow_nil?": false,
|
||||||
|
"default": "nil",
|
||||||
|
"generated?": false,
|
||||||
|
"precision": null,
|
||||||
|
"primary_key?": false,
|
||||||
|
"references": null,
|
||||||
|
"scale": null,
|
||||||
|
"size": null,
|
||||||
|
"source": "last_name",
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"allow_nil?": false,
|
||||||
|
"default": "nil",
|
||||||
|
"generated?": false,
|
||||||
|
"precision": null,
|
||||||
|
"primary_key?": false,
|
||||||
|
"references": null,
|
||||||
|
"scale": null,
|
||||||
|
"size": null,
|
||||||
|
"source": "email",
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"allow_nil?": true,
|
||||||
|
"default": "nil",
|
||||||
|
"generated?": false,
|
||||||
|
"precision": null,
|
||||||
|
"primary_key?": false,
|
||||||
|
"references": null,
|
||||||
|
"scale": null,
|
||||||
|
"size": null,
|
||||||
|
"source": "paid",
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"allow_nil?": true,
|
||||||
|
"default": "nil",
|
||||||
|
"generated?": false,
|
||||||
|
"precision": null,
|
||||||
|
"primary_key?": false,
|
||||||
|
"references": null,
|
||||||
|
"scale": null,
|
||||||
|
"size": null,
|
||||||
|
"source": "phone_number",
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"allow_nil?": true,
|
||||||
|
"default": "nil",
|
||||||
|
"generated?": false,
|
||||||
|
"precision": null,
|
||||||
|
"primary_key?": false,
|
||||||
|
"references": null,
|
||||||
|
"scale": null,
|
||||||
|
"size": null,
|
||||||
|
"source": "join_date",
|
||||||
|
"type": "date"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"allow_nil?": true,
|
||||||
|
"default": "nil",
|
||||||
|
"generated?": false,
|
||||||
|
"precision": null,
|
||||||
|
"primary_key?": false,
|
||||||
|
"references": null,
|
||||||
|
"scale": null,
|
||||||
|
"size": null,
|
||||||
|
"source": "exit_date",
|
||||||
|
"type": "date"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"allow_nil?": true,
|
||||||
|
"default": "nil",
|
||||||
|
"generated?": false,
|
||||||
|
"precision": null,
|
||||||
|
"primary_key?": false,
|
||||||
|
"references": null,
|
||||||
|
"scale": null,
|
||||||
|
"size": null,
|
||||||
|
"source": "notes",
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"allow_nil?": true,
|
||||||
|
"default": "nil",
|
||||||
|
"generated?": false,
|
||||||
|
"precision": null,
|
||||||
|
"primary_key?": false,
|
||||||
|
"references": null,
|
||||||
|
"scale": null,
|
||||||
|
"size": null,
|
||||||
|
"source": "city",
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"allow_nil?": true,
|
||||||
|
"default": "nil",
|
||||||
|
"generated?": false,
|
||||||
|
"precision": null,
|
||||||
|
"primary_key?": false,
|
||||||
|
"references": null,
|
||||||
|
"scale": null,
|
||||||
|
"size": null,
|
||||||
|
"source": "street",
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"allow_nil?": true,
|
||||||
|
"default": "nil",
|
||||||
|
"generated?": false,
|
||||||
|
"precision": null,
|
||||||
|
"primary_key?": false,
|
||||||
|
"references": null,
|
||||||
|
"scale": null,
|
||||||
|
"size": null,
|
||||||
|
"source": "house_number",
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"allow_nil?": true,
|
||||||
|
"default": "nil",
|
||||||
|
"generated?": false,
|
||||||
|
"precision": null,
|
||||||
|
"primary_key?": false,
|
||||||
|
"references": null,
|
||||||
|
"scale": null,
|
||||||
|
"size": null,
|
||||||
|
"source": "postal_code",
|
||||||
|
"type": "text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"allow_nil?": true,
|
||||||
|
"default": "nil",
|
||||||
|
"generated?": false,
|
||||||
|
"precision": null,
|
||||||
|
"primary_key?": false,
|
||||||
|
"references": null,
|
||||||
|
"scale": null,
|
||||||
|
"size": null,
|
||||||
|
"source": "search_vector",
|
||||||
|
"type": "tsvector"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"base_filter": null,
|
||||||
|
"check_constraints": [],
|
||||||
|
"custom_indexes": [],
|
||||||
|
"custom_statements": [],
|
||||||
|
"has_create_action": true,
|
||||||
|
"hash": "247CACFA5C8FD24BDD553252E9BBF489E8FE54F60704383B6BE66C616D203A65",
|
||||||
|
"identities": [
|
||||||
|
{
|
||||||
|
"all_tenants?": false,
|
||||||
|
"base_filter": null,
|
||||||
|
"index_name": "members_unique_email_index",
|
||||||
|
"keys": [
|
||||||
|
{
|
||||||
|
"type": "atom",
|
||||||
|
"value": "email"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"name": "unique_email",
|
||||||
|
"nils_distinct?": true,
|
||||||
|
"where": null
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"multitenancy": {
|
||||||
|
"attribute": null,
|
||||||
|
"global": null,
|
||||||
|
"strategy": null
|
||||||
|
},
|
||||||
|
"repo": "Elixir.Mv.Repo",
|
||||||
|
"schema": null,
|
||||||
|
"table": "members"
|
||||||
|
}
|
||||||
9
rel/overlays/bin/docker-entrypoint.sh
Executable file
9
rel/overlays/bin/docker-entrypoint.sh
Executable file
|
|
@ -0,0 +1,9 @@
|
||||||
|
#!/bin/sh
|
||||||
|
set -e
|
||||||
|
|
||||||
|
echo "==> Running database migrations..."
|
||||||
|
/app/bin/migrate
|
||||||
|
|
||||||
|
echo "==> Starting application..."
|
||||||
|
exec /app/bin/server
|
||||||
|
|
||||||
|
|
@ -69,7 +69,7 @@ defmodule Mv.Membership.FuzzySearchTest do
|
||||||
ids = Enum.map(result, & &1.id)
|
ids = Enum.map(result, & &1.id)
|
||||||
assert thomas.id in ids
|
assert thomas.id in ids
|
||||||
refute jane.id in ids
|
refute jane.id in ids
|
||||||
assert length(ids) >= 1
|
assert not Enum.empty?(ids)
|
||||||
end
|
end
|
||||||
|
|
||||||
test "empty query returns all members" do
|
test "empty query returns all members" do
|
||||||
|
|
|
||||||
|
|
@ -11,4 +11,70 @@ defmodule Mv.Membership.MemberFieldVisibilityTest do
|
||||||
use Mv.DataCase, async: true
|
use Mv.DataCase, async: true
|
||||||
|
|
||||||
alias Mv.Membership.Member
|
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
|
end
|
||||||
|
|
|
||||||
702
test/membership/member_search_with_custom_fields_test.exs
Normal file
702
test/membership/member_search_with_custom_fields_test.exs
Normal file
|
|
@ -0,0 +1,702 @@
|
||||||
|
defmodule Mv.Membership.MemberSearchWithCustomFieldsTest do
|
||||||
|
@moduledoc """
|
||||||
|
Tests for full-text search including custom_field_values.
|
||||||
|
|
||||||
|
Tests verify that custom field values are included in the search_vector
|
||||||
|
and can be found through the fuzzy_search functionality.
|
||||||
|
"""
|
||||||
|
use Mv.DataCase, async: false
|
||||||
|
|
||||||
|
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 fields for different types
|
||||||
|
{:ok, string_field} =
|
||||||
|
CustomField
|
||||||
|
|> Ash.Changeset.for_create(:create, %{
|
||||||
|
name: "membership_number",
|
||||||
|
value_type: :string
|
||||||
|
})
|
||||||
|
|> Ash.create()
|
||||||
|
|
||||||
|
{:ok, integer_field} =
|
||||||
|
CustomField
|
||||||
|
|> Ash.Changeset.for_create(:create, %{
|
||||||
|
name: "member_id_number",
|
||||||
|
value_type: :integer
|
||||||
|
})
|
||||||
|
|> Ash.create()
|
||||||
|
|
||||||
|
{:ok, email_field} =
|
||||||
|
CustomField
|
||||||
|
|> Ash.Changeset.for_create(:create, %{
|
||||||
|
name: "secondary_email",
|
||||||
|
value_type: :email
|
||||||
|
})
|
||||||
|
|> Ash.create()
|
||||||
|
|
||||||
|
{:ok, date_field} =
|
||||||
|
CustomField
|
||||||
|
|> Ash.Changeset.for_create(:create, %{
|
||||||
|
name: "birthday",
|
||||||
|
value_type: :date
|
||||||
|
})
|
||||||
|
|> Ash.create()
|
||||||
|
|
||||||
|
{:ok, boolean_field} =
|
||||||
|
CustomField
|
||||||
|
|> Ash.Changeset.for_create(:create, %{
|
||||||
|
name: "newsletter",
|
||||||
|
value_type: :boolean
|
||||||
|
})
|
||||||
|
|> Ash.create()
|
||||||
|
|
||||||
|
%{
|
||||||
|
member1: member1,
|
||||||
|
member2: member2,
|
||||||
|
member3: member3,
|
||||||
|
string_field: string_field,
|
||||||
|
integer_field: integer_field,
|
||||||
|
email_field: email_field,
|
||||||
|
date_field: date_field,
|
||||||
|
boolean_field: boolean_field
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "search with custom field values" do
|
||||||
|
test "finds member by string custom field value", %{
|
||||||
|
member1: member1,
|
||||||
|
string_field: string_field
|
||||||
|
} do
|
||||||
|
# Create custom field value
|
||||||
|
{:ok, _cfv} =
|
||||||
|
CustomFieldValue
|
||||||
|
|> Ash.Changeset.for_create(:create, %{
|
||||||
|
member_id: member1.id,
|
||||||
|
custom_field_id: string_field.id,
|
||||||
|
value: %{"_union_type" => "string", "_union_value" => "MEMBER12345"}
|
||||||
|
})
|
||||||
|
|> Ash.create()
|
||||||
|
|
||||||
|
# Force search_vector update by reloading member
|
||||||
|
{:ok, _updated_member} =
|
||||||
|
member1
|
||||||
|
|> Ash.Changeset.for_update(:update_member, %{})
|
||||||
|
|> Ash.update()
|
||||||
|
|
||||||
|
# Search for the custom field value
|
||||||
|
results =
|
||||||
|
Member
|
||||||
|
|> Member.fuzzy_search(%{query: "MEMBER12345"})
|
||||||
|
|> Ash.read!()
|
||||||
|
|
||||||
|
assert length(results) == 1
|
||||||
|
assert List.first(results).id == member1.id
|
||||||
|
end
|
||||||
|
|
||||||
|
test "finds member by integer custom field value", %{
|
||||||
|
member1: member1,
|
||||||
|
integer_field: integer_field
|
||||||
|
} do
|
||||||
|
# Create custom field value
|
||||||
|
{:ok, _cfv} =
|
||||||
|
CustomFieldValue
|
||||||
|
|> Ash.Changeset.for_create(:create, %{
|
||||||
|
member_id: member1.id,
|
||||||
|
custom_field_id: integer_field.id,
|
||||||
|
value: %{"_union_type" => "integer", "_union_value" => 42_424}
|
||||||
|
})
|
||||||
|
|> Ash.create()
|
||||||
|
|
||||||
|
# Force search_vector update
|
||||||
|
{:ok, _updated_member} =
|
||||||
|
member1
|
||||||
|
|> Ash.Changeset.for_update(:update_member, %{})
|
||||||
|
|> Ash.update()
|
||||||
|
|
||||||
|
# Search for the custom field value
|
||||||
|
results =
|
||||||
|
Member
|
||||||
|
|> Member.fuzzy_search(%{query: "42424"})
|
||||||
|
|> Ash.read!()
|
||||||
|
|
||||||
|
assert length(results) == 1
|
||||||
|
assert List.first(results).id == member1.id
|
||||||
|
end
|
||||||
|
|
||||||
|
test "finds member by email custom field value", %{
|
||||||
|
member1: member1,
|
||||||
|
email_field: email_field
|
||||||
|
} do
|
||||||
|
# Create custom field value
|
||||||
|
{:ok, _cfv} =
|
||||||
|
CustomFieldValue
|
||||||
|
|> Ash.Changeset.for_create(:create, %{
|
||||||
|
member_id: member1.id,
|
||||||
|
custom_field_id: email_field.id,
|
||||||
|
value: %{"_union_type" => "email", "_union_value" => "alice.secondary@example.com"}
|
||||||
|
})
|
||||||
|
|> Ash.create()
|
||||||
|
|
||||||
|
# Force search_vector update
|
||||||
|
{:ok, _updated_member} =
|
||||||
|
member1
|
||||||
|
|> Ash.Changeset.for_update(:update_member, %{})
|
||||||
|
|> Ash.update()
|
||||||
|
|
||||||
|
# Search for partial custom field value (should work via FTS or custom field filter)
|
||||||
|
results =
|
||||||
|
Member
|
||||||
|
|> Member.fuzzy_search(%{query: "alice.secondary"})
|
||||||
|
|> Ash.read!()
|
||||||
|
|
||||||
|
assert length(results) == 1
|
||||||
|
assert List.first(results).id == member1.id
|
||||||
|
|
||||||
|
# Search for full email address (should work via custom field filter LIKE)
|
||||||
|
results_full =
|
||||||
|
Member
|
||||||
|
|> Member.fuzzy_search(%{query: "alice.secondary@example.com"})
|
||||||
|
|> Ash.read!()
|
||||||
|
|
||||||
|
assert length(results_full) == 1
|
||||||
|
assert List.first(results_full).id == member1.id
|
||||||
|
|
||||||
|
# Search for domain part (should work via FTS or custom field filter)
|
||||||
|
# Note: May return multiple results if other members have same domain
|
||||||
|
results_domain =
|
||||||
|
Member
|
||||||
|
|> Member.fuzzy_search(%{query: "example.com"})
|
||||||
|
|> Ash.read!()
|
||||||
|
|
||||||
|
# Verify that member1 is in the results (may have other members too)
|
||||||
|
ids = Enum.map(results_domain, & &1.id)
|
||||||
|
assert member1.id in ids
|
||||||
|
end
|
||||||
|
|
||||||
|
test "finds member by date custom field value", %{
|
||||||
|
member1: member1,
|
||||||
|
date_field: date_field
|
||||||
|
} do
|
||||||
|
# Create custom field value
|
||||||
|
{:ok, _cfv} =
|
||||||
|
CustomFieldValue
|
||||||
|
|> Ash.Changeset.for_create(:create, %{
|
||||||
|
member_id: member1.id,
|
||||||
|
custom_field_id: date_field.id,
|
||||||
|
value: %{"_union_type" => "date", "_union_value" => ~D[1990-05-15]}
|
||||||
|
})
|
||||||
|
|> Ash.create()
|
||||||
|
|
||||||
|
# Force search_vector update
|
||||||
|
{:ok, _updated_member} =
|
||||||
|
member1
|
||||||
|
|> Ash.Changeset.for_update(:update_member, %{})
|
||||||
|
|> Ash.update()
|
||||||
|
|
||||||
|
# Search for the custom field value (date is stored as text in search_vector)
|
||||||
|
results =
|
||||||
|
Member
|
||||||
|
|> Member.fuzzy_search(%{query: "1990-05-15"})
|
||||||
|
|> Ash.read!()
|
||||||
|
|
||||||
|
assert length(results) == 1
|
||||||
|
assert List.first(results).id == member1.id
|
||||||
|
end
|
||||||
|
|
||||||
|
test "finds member by boolean custom field value", %{
|
||||||
|
member1: member1,
|
||||||
|
boolean_field: boolean_field
|
||||||
|
} do
|
||||||
|
# Create custom field value
|
||||||
|
{:ok, _cfv} =
|
||||||
|
CustomFieldValue
|
||||||
|
|> Ash.Changeset.for_create(:create, %{
|
||||||
|
member_id: member1.id,
|
||||||
|
custom_field_id: boolean_field.id,
|
||||||
|
value: %{"_union_type" => "boolean", "_union_value" => true}
|
||||||
|
})
|
||||||
|
|> Ash.create()
|
||||||
|
|
||||||
|
# Force search_vector update
|
||||||
|
{:ok, _updated_member} =
|
||||||
|
member1
|
||||||
|
|> Ash.Changeset.for_update(:update_member, %{})
|
||||||
|
|> Ash.update()
|
||||||
|
|
||||||
|
# Search for the custom field value (boolean is stored as "true" or "false" text)
|
||||||
|
results =
|
||||||
|
Member
|
||||||
|
|> Member.fuzzy_search(%{query: "true"})
|
||||||
|
|> Ash.read!()
|
||||||
|
|
||||||
|
# Note: "true" might match other things, so we check that member1 is in results
|
||||||
|
assert Enum.any?(results, fn m -> m.id == member1.id end)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "custom field value update triggers search_vector update", %{
|
||||||
|
member1: member1,
|
||||||
|
string_field: string_field
|
||||||
|
} do
|
||||||
|
# Create initial custom field value
|
||||||
|
{:ok, cfv} =
|
||||||
|
CustomFieldValue
|
||||||
|
|> Ash.Changeset.for_create(:create, %{
|
||||||
|
member_id: member1.id,
|
||||||
|
custom_field_id: string_field.id,
|
||||||
|
value: %{"_union_type" => "string", "_union_value" => "OLDVALUE"}
|
||||||
|
})
|
||||||
|
|> Ash.create()
|
||||||
|
|
||||||
|
# Force search_vector update
|
||||||
|
{:ok, _updated_member} =
|
||||||
|
member1
|
||||||
|
|> Ash.Changeset.for_update(:update_member, %{})
|
||||||
|
|> Ash.update()
|
||||||
|
|
||||||
|
# Update custom field value
|
||||||
|
{:ok, _updated_cfv} =
|
||||||
|
cfv
|
||||||
|
|> Ash.Changeset.for_update(:update, %{
|
||||||
|
value: %{"_union_type" => "string", "_union_value" => "NEWVALUE123"}
|
||||||
|
})
|
||||||
|
|> Ash.update()
|
||||||
|
|
||||||
|
# Search for the new value
|
||||||
|
results =
|
||||||
|
Member
|
||||||
|
|> Member.fuzzy_search(%{query: "NEWVALUE123"})
|
||||||
|
|> Ash.read!()
|
||||||
|
|
||||||
|
assert length(results) == 1
|
||||||
|
assert List.first(results).id == member1.id
|
||||||
|
|
||||||
|
# Old value should not be found
|
||||||
|
old_results =
|
||||||
|
Member
|
||||||
|
|> Member.fuzzy_search(%{query: "OLDVALUE"})
|
||||||
|
|> Ash.read!()
|
||||||
|
|
||||||
|
refute Enum.any?(old_results, fn m -> m.id == member1.id end)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "custom field value delete triggers search_vector update", %{
|
||||||
|
member1: member1,
|
||||||
|
string_field: string_field
|
||||||
|
} do
|
||||||
|
# Create custom field value
|
||||||
|
{:ok, cfv} =
|
||||||
|
CustomFieldValue
|
||||||
|
|> Ash.Changeset.for_create(:create, %{
|
||||||
|
member_id: member1.id,
|
||||||
|
custom_field_id: string_field.id,
|
||||||
|
value: %{"_union_type" => "string", "_union_value" => "TOBEDELETED"}
|
||||||
|
})
|
||||||
|
|> Ash.create()
|
||||||
|
|
||||||
|
# Force search_vector update
|
||||||
|
{:ok, _updated_member} =
|
||||||
|
member1
|
||||||
|
|> Ash.Changeset.for_update(:update_member, %{})
|
||||||
|
|> Ash.update()
|
||||||
|
|
||||||
|
# Verify it's searchable
|
||||||
|
results =
|
||||||
|
Member
|
||||||
|
|> Member.fuzzy_search(%{query: "TOBEDELETED"})
|
||||||
|
|> Ash.read!()
|
||||||
|
|
||||||
|
assert length(results) == 1
|
||||||
|
assert List.first(results).id == member1.id
|
||||||
|
|
||||||
|
# Delete custom field value
|
||||||
|
assert :ok = Ash.destroy(cfv)
|
||||||
|
|
||||||
|
# Value should no longer be found
|
||||||
|
deleted_results =
|
||||||
|
Member
|
||||||
|
|> Member.fuzzy_search(%{query: "TOBEDELETED"})
|
||||||
|
|> Ash.read!()
|
||||||
|
|
||||||
|
refute Enum.any?(deleted_results, fn m -> m.id == member1.id end)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "custom field value create triggers search_vector update", %{
|
||||||
|
member1: member1,
|
||||||
|
string_field: string_field
|
||||||
|
} do
|
||||||
|
# Create custom field value (trigger should update search_vector automatically)
|
||||||
|
{:ok, _cfv} =
|
||||||
|
CustomFieldValue
|
||||||
|
|> Ash.Changeset.for_create(:create, %{
|
||||||
|
member_id: member1.id,
|
||||||
|
custom_field_id: string_field.id,
|
||||||
|
value: %{"_union_type" => "string", "_union_value" => "AUTOUPDATE"}
|
||||||
|
})
|
||||||
|
|> Ash.create()
|
||||||
|
|
||||||
|
# Search should find it immediately (trigger should have updated search_vector)
|
||||||
|
results =
|
||||||
|
Member
|
||||||
|
|> Member.fuzzy_search(%{query: "AUTOUPDATE"})
|
||||||
|
|> Ash.read!()
|
||||||
|
|
||||||
|
assert length(results) == 1
|
||||||
|
assert List.first(results).id == member1.id
|
||||||
|
end
|
||||||
|
|
||||||
|
test "member update includes custom field values in search_vector", %{
|
||||||
|
member1: member1,
|
||||||
|
string_field: string_field
|
||||||
|
} do
|
||||||
|
# Create custom field value
|
||||||
|
{:ok, _cfv} =
|
||||||
|
CustomFieldValue
|
||||||
|
|> Ash.Changeset.for_create(:create, %{
|
||||||
|
member_id: member1.id,
|
||||||
|
custom_field_id: string_field.id,
|
||||||
|
value: %{"_union_type" => "string", "_union_value" => "MEMBERUPDATE"}
|
||||||
|
})
|
||||||
|
|> Ash.create()
|
||||||
|
|
||||||
|
# Update member (should trigger search_vector update including custom fields)
|
||||||
|
{:ok, _updated_member} =
|
||||||
|
member1
|
||||||
|
|> Ash.Changeset.for_update(:update_member, %{notes: "Updated notes"})
|
||||||
|
|> Ash.update()
|
||||||
|
|
||||||
|
# Search should find the custom field value
|
||||||
|
results =
|
||||||
|
Member
|
||||||
|
|> Member.fuzzy_search(%{query: "MEMBERUPDATE"})
|
||||||
|
|> Ash.read!()
|
||||||
|
|
||||||
|
assert length(results) == 1
|
||||||
|
assert List.first(results).id == member1.id
|
||||||
|
end
|
||||||
|
|
||||||
|
test "multiple custom field values are all searchable", %{
|
||||||
|
member1: member1,
|
||||||
|
string_field: string_field,
|
||||||
|
integer_field: integer_field,
|
||||||
|
email_field: email_field
|
||||||
|
} do
|
||||||
|
# Create multiple custom field values
|
||||||
|
{:ok, _cfv1} =
|
||||||
|
CustomFieldValue
|
||||||
|
|> Ash.Changeset.for_create(:create, %{
|
||||||
|
member_id: member1.id,
|
||||||
|
custom_field_id: string_field.id,
|
||||||
|
value: %{"_union_type" => "string", "_union_value" => "MULTI1"}
|
||||||
|
})
|
||||||
|
|> Ash.create()
|
||||||
|
|
||||||
|
{:ok, _cfv2} =
|
||||||
|
CustomFieldValue
|
||||||
|
|> Ash.Changeset.for_create(:create, %{
|
||||||
|
member_id: member1.id,
|
||||||
|
custom_field_id: integer_field.id,
|
||||||
|
value: %{"_union_type" => "integer", "_union_value" => 99_999}
|
||||||
|
})
|
||||||
|
|> Ash.create()
|
||||||
|
|
||||||
|
{:ok, _cfv3} =
|
||||||
|
CustomFieldValue
|
||||||
|
|> Ash.Changeset.for_create(:create, %{
|
||||||
|
member_id: member1.id,
|
||||||
|
custom_field_id: email_field.id,
|
||||||
|
value: %{"_union_type" => "email", "_union_value" => "multi@test.com"}
|
||||||
|
})
|
||||||
|
|> Ash.create()
|
||||||
|
|
||||||
|
# Force search_vector update
|
||||||
|
{:ok, _updated_member} =
|
||||||
|
member1
|
||||||
|
|> Ash.Changeset.for_update(:update_member, %{})
|
||||||
|
|> Ash.update()
|
||||||
|
|
||||||
|
# All values should be searchable
|
||||||
|
results1 =
|
||||||
|
Member
|
||||||
|
|> Member.fuzzy_search(%{query: "MULTI1"})
|
||||||
|
|> Ash.read!()
|
||||||
|
|
||||||
|
assert Enum.any?(results1, fn m -> m.id == member1.id end)
|
||||||
|
|
||||||
|
results2 =
|
||||||
|
Member
|
||||||
|
|> Member.fuzzy_search(%{query: "99999"})
|
||||||
|
|> Ash.read!()
|
||||||
|
|
||||||
|
assert Enum.any?(results2, fn m -> m.id == member1.id end)
|
||||||
|
|
||||||
|
results3 =
|
||||||
|
Member
|
||||||
|
|> Member.fuzzy_search(%{query: "multi@test.com"})
|
||||||
|
|> Ash.read!()
|
||||||
|
|
||||||
|
assert Enum.any?(results3, fn m -> m.id == member1.id end)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "finds member by custom field value with numbers in text field (e.g. phone number)", %{
|
||||||
|
member1: member1,
|
||||||
|
string_field: string_field
|
||||||
|
} do
|
||||||
|
# Create custom field value with numbers and text (like phone number or ID)
|
||||||
|
{:ok, _cfv} =
|
||||||
|
CustomFieldValue
|
||||||
|
|> Ash.Changeset.for_create(:create, %{
|
||||||
|
member_id: member1.id,
|
||||||
|
custom_field_id: string_field.id,
|
||||||
|
value: %{"_union_type" => "string", "_union_value" => "M-123-456"}
|
||||||
|
})
|
||||||
|
|> Ash.create()
|
||||||
|
|
||||||
|
# Force search_vector update
|
||||||
|
{:ok, _updated_member} =
|
||||||
|
member1
|
||||||
|
|> Ash.Changeset.for_update(:update_member, %{})
|
||||||
|
|> Ash.update()
|
||||||
|
|
||||||
|
# Search for full value (should work via search_vector)
|
||||||
|
results_full =
|
||||||
|
Member
|
||||||
|
|> Member.fuzzy_search(%{query: "M-123-456"})
|
||||||
|
|> Ash.read!()
|
||||||
|
|
||||||
|
assert Enum.any?(results_full, fn m -> m.id == member1.id end),
|
||||||
|
"Full value search should find member via search_vector"
|
||||||
|
|
||||||
|
# Note: Partial substring search may require additional implementation
|
||||||
|
# For now, we test that the full value is searchable, which is the primary use case
|
||||||
|
# Substring matching for custom fields may need to be implemented separately
|
||||||
|
end
|
||||||
|
|
||||||
|
test "finds member by phone number in Emergency Contact custom field", %{
|
||||||
|
member1: member1
|
||||||
|
} do
|
||||||
|
# Create Emergency Contact custom field
|
||||||
|
{:ok, emergency_contact_field} =
|
||||||
|
CustomField
|
||||||
|
|> Ash.Changeset.for_create(:create, %{
|
||||||
|
name: "Emergency Contact",
|
||||||
|
value_type: :string
|
||||||
|
})
|
||||||
|
|> Ash.create()
|
||||||
|
|
||||||
|
# Create custom field value with phone number
|
||||||
|
phone_number = "+49 123 456789"
|
||||||
|
|
||||||
|
{:ok, _cfv} =
|
||||||
|
CustomFieldValue
|
||||||
|
|> Ash.Changeset.for_create(:create, %{
|
||||||
|
member_id: member1.id,
|
||||||
|
custom_field_id: emergency_contact_field.id,
|
||||||
|
value: %{"_union_type" => "string", "_union_value" => phone_number}
|
||||||
|
})
|
||||||
|
|> Ash.create()
|
||||||
|
|
||||||
|
# Force search_vector update
|
||||||
|
{:ok, _updated_member} =
|
||||||
|
member1
|
||||||
|
|> Ash.Changeset.for_update(:update_member, %{})
|
||||||
|
|> Ash.update()
|
||||||
|
|
||||||
|
# Search for full phone number (should work via search_vector)
|
||||||
|
results_full =
|
||||||
|
Member
|
||||||
|
|> Member.fuzzy_search(%{query: phone_number})
|
||||||
|
|> Ash.read!()
|
||||||
|
|
||||||
|
assert Enum.any?(results_full, fn m -> m.id == member1.id end),
|
||||||
|
"Full phone number search should find member via search_vector"
|
||||||
|
|
||||||
|
# Note: Partial substring search may require additional implementation
|
||||||
|
# For now, we test that the full phone number is searchable, which is the primary use case
|
||||||
|
# Substring matching for custom fields may need to be implemented separately
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "custom field substring search (ILIKE)" do
|
||||||
|
test "finds member by prefix of custom field value", %{
|
||||||
|
member1: member1,
|
||||||
|
string_field: string_field
|
||||||
|
} do
|
||||||
|
# Create custom field value with a distinct word
|
||||||
|
{:ok, _cfv} =
|
||||||
|
CustomFieldValue
|
||||||
|
|> Ash.Changeset.for_create(:create, %{
|
||||||
|
member_id: member1.id,
|
||||||
|
custom_field_id: string_field.id,
|
||||||
|
value: %{"_union_type" => "string", "_union_value" => "Premium"}
|
||||||
|
})
|
||||||
|
|> Ash.create()
|
||||||
|
|
||||||
|
# Test prefix searches - should all find the member
|
||||||
|
for prefix <- ["Premium", "Premiu", "Premi", "Prem", "Pre"] do
|
||||||
|
results =
|
||||||
|
Member
|
||||||
|
|> Member.fuzzy_search(%{query: prefix})
|
||||||
|
|> Ash.read!()
|
||||||
|
|
||||||
|
assert Enum.any?(results, fn m -> m.id == member1.id end),
|
||||||
|
"Prefix '#{prefix}' should find member with custom field 'Premium'"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
test "custom field search is case-insensitive", %{
|
||||||
|
member1: member1,
|
||||||
|
string_field: string_field
|
||||||
|
} do
|
||||||
|
# Create custom field value
|
||||||
|
{:ok, _cfv} =
|
||||||
|
CustomFieldValue
|
||||||
|
|> Ash.Changeset.for_create(:create, %{
|
||||||
|
member_id: member1.id,
|
||||||
|
custom_field_id: string_field.id,
|
||||||
|
value: %{"_union_type" => "string", "_union_value" => "GoldMember"}
|
||||||
|
})
|
||||||
|
|> Ash.create()
|
||||||
|
|
||||||
|
# Test case variations - should all find the member
|
||||||
|
for variant <- [
|
||||||
|
"GoldMember",
|
||||||
|
"goldmember",
|
||||||
|
"GOLDMEMBER",
|
||||||
|
"GoLdMeMbEr",
|
||||||
|
"gold",
|
||||||
|
"GOLD",
|
||||||
|
"Gold"
|
||||||
|
] do
|
||||||
|
results =
|
||||||
|
Member
|
||||||
|
|> Member.fuzzy_search(%{query: variant})
|
||||||
|
|> Ash.read!()
|
||||||
|
|
||||||
|
assert Enum.any?(results, fn m -> m.id == member1.id end),
|
||||||
|
"Case variant '#{variant}' should find member with custom field 'GoldMember'"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
test "finds member by suffix/middle of custom field value", %{
|
||||||
|
member1: member1,
|
||||||
|
string_field: string_field
|
||||||
|
} do
|
||||||
|
# Create custom field value
|
||||||
|
{:ok, _cfv} =
|
||||||
|
CustomFieldValue
|
||||||
|
|> Ash.Changeset.for_create(:create, %{
|
||||||
|
member_id: member1.id,
|
||||||
|
custom_field_id: string_field.id,
|
||||||
|
value: %{"_union_type" => "string", "_union_value" => "ActiveMember"}
|
||||||
|
})
|
||||||
|
|> Ash.create()
|
||||||
|
|
||||||
|
# Test suffix and middle substring searches
|
||||||
|
for substring <- ["Member", "ember", "tiveMem", "ctive"] do
|
||||||
|
results =
|
||||||
|
Member
|
||||||
|
|> Member.fuzzy_search(%{query: substring})
|
||||||
|
|> Ash.read!()
|
||||||
|
|
||||||
|
assert Enum.any?(results, fn m -> m.id == member1.id end),
|
||||||
|
"Substring '#{substring}' should find member with custom field 'ActiveMember'"
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
test "finds correct member among multiple with different custom field values", %{
|
||||||
|
member1: member1,
|
||||||
|
member2: member2,
|
||||||
|
member3: member3,
|
||||||
|
string_field: string_field
|
||||||
|
} do
|
||||||
|
# Create different custom field values for each member
|
||||||
|
{:ok, _cfv1} =
|
||||||
|
CustomFieldValue
|
||||||
|
|> Ash.Changeset.for_create(:create, %{
|
||||||
|
member_id: member1.id,
|
||||||
|
custom_field_id: string_field.id,
|
||||||
|
value: %{"_union_type" => "string", "_union_value" => "Beginner"}
|
||||||
|
})
|
||||||
|
|> Ash.create()
|
||||||
|
|
||||||
|
{:ok, _cfv2} =
|
||||||
|
CustomFieldValue
|
||||||
|
|> Ash.Changeset.for_create(:create, %{
|
||||||
|
member_id: member2.id,
|
||||||
|
custom_field_id: string_field.id,
|
||||||
|
value: %{"_union_type" => "string", "_union_value" => "Advanced"}
|
||||||
|
})
|
||||||
|
|> Ash.create()
|
||||||
|
|
||||||
|
{:ok, _cfv3} =
|
||||||
|
CustomFieldValue
|
||||||
|
|> Ash.Changeset.for_create(:create, %{
|
||||||
|
member_id: member3.id,
|
||||||
|
custom_field_id: string_field.id,
|
||||||
|
value: %{"_union_type" => "string", "_union_value" => "Expert"}
|
||||||
|
})
|
||||||
|
|> Ash.create()
|
||||||
|
|
||||||
|
# Search for "Begin" - should only find member1
|
||||||
|
results_begin =
|
||||||
|
Member
|
||||||
|
|> Member.fuzzy_search(%{query: "Begin"})
|
||||||
|
|> Ash.read!()
|
||||||
|
|
||||||
|
assert length(results_begin) == 1
|
||||||
|
assert List.first(results_begin).id == member1.id
|
||||||
|
|
||||||
|
# Search for "Advan" - should only find member2
|
||||||
|
results_advan =
|
||||||
|
Member
|
||||||
|
|> Member.fuzzy_search(%{query: "Advan"})
|
||||||
|
|> Ash.read!()
|
||||||
|
|
||||||
|
assert length(results_advan) == 1
|
||||||
|
assert List.first(results_advan).id == member2.id
|
||||||
|
|
||||||
|
# Search for "Exper" - should only find member3
|
||||||
|
results_exper =
|
||||||
|
Member
|
||||||
|
|> Member.fuzzy_search(%{query: "Exper"})
|
||||||
|
|> Ash.read!()
|
||||||
|
|
||||||
|
assert length(results_exper) == 1
|
||||||
|
assert List.first(results_exper).id == member3.id
|
||||||
|
end
|
||||||
|
|
||||||
|
# Note: Legacy format (type/value) is supported via the SQL ILIKE query on value->>'value'
|
||||||
|
# This is tested implicitly by the migration trigger which handles both formats.
|
||||||
|
# The Ash union type only accepts the new format (_union_type/_union_value) for creation,
|
||||||
|
# but the search works on existing legacy data.
|
||||||
|
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
|
||||||
|
|
@ -150,35 +150,27 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
|
||||||
assert has_element?(view, "[data-testid='email'] .opacity-40")
|
assert has_element?(view, "[data-testid='email'] .opacity-40")
|
||||||
end
|
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)
|
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")
|
{:ok, _view, html_neutral} = live(conn, "/members")
|
||||||
|
|
||||||
# Count neutral icons (should be 7 - one for each field)
|
# Count active icons (should be exactly 1 - ascending for default sort field)
|
||||||
neutral_count =
|
|
||||||
html_neutral |> String.split("hero-chevron-up-down") |> length() |> Kernel.-(1)
|
|
||||||
|
|
||||||
assert neutral_count == 7
|
|
||||||
|
|
||||||
# Count active icons (should be 1)
|
|
||||||
up_count = html_neutral |> String.split("hero-chevron-up ") |> length() |> Kernel.-(1)
|
up_count = html_neutral |> String.split("hero-chevron-up ") |> length() |> Kernel.-(1)
|
||||||
down_count = html_neutral |> String.split("hero-chevron-down ") |> 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
|
assert up_count == 1, "Expected exactly 1 ascending icon, got #{up_count}"
|
||||||
{:ok, _view, html_asc} = live(conn, "/members?sort_field=first_name&sort_order=asc")
|
assert down_count == 0, "Expected 0 descending icons, got #{down_count}"
|
||||||
|
|
||||||
# Should have exactly 1 ascending icon and 7 neutral icons
|
# Test descending state
|
||||||
up_count = html_asc |> String.split("hero-chevron-up ") |> length() |> Kernel.-(1)
|
{:ok, _view, html_desc} = live(conn, "/members?sort_field=first_name&sort_order=desc")
|
||||||
neutral_count = html_asc |> String.split("hero-chevron-up-down") |> length() |> Kernel.-(1)
|
|
||||||
down_count = html_asc |> String.split("hero-chevron-down ") |> length() |> Kernel.-(1)
|
|
||||||
|
|
||||||
assert up_count == 1
|
up_count = html_desc |> String.split("hero-chevron-up ") |> length() |> Kernel.-(1)
|
||||||
assert neutral_count == 7
|
down_count = html_desc |> String.split("hero-chevron-down ") |> length() |> Kernel.-(1)
|
||||||
assert down_count == 0
|
|
||||||
|
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
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
defmodule MvWeb.CustomFieldLive.DeletionTest do
|
defmodule MvWeb.CustomFieldLive.DeletionTest do
|
||||||
@moduledoc """
|
@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:
|
Tests cover:
|
||||||
- Opening deletion confirmation modal
|
- Opening deletion confirmation modal
|
||||||
|
|
@ -39,11 +40,11 @@ defmodule MvWeb.CustomFieldLive.DeletionTest do
|
||||||
# Create custom field value
|
# Create custom field value
|
||||||
create_custom_field_value(member, custom_field, "test")
|
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
|
view
|
||||||
|> element("a", "Delete")
|
|> element("#custom-fields-component a", "Delete")
|
||||||
|> render_click()
|
|> render_click()
|
||||||
|
|
||||||
# Modal should be visible
|
# 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(member1, custom_field, "test1")
|
||||||
create_custom_field_value(member2, custom_field, "test2")
|
create_custom_field_value(member2, custom_field, "test2")
|
||||||
|
|
||||||
{:ok, view, _html} = live(conn, ~p"/custom_fields")
|
{:ok, view, _html} = live(conn, ~p"/settings")
|
||||||
|
|
||||||
view
|
view
|
||||||
|> element("a", "Delete")
|
|> element("#custom-fields-component a", "Delete")
|
||||||
|> render_click()
|
|> render_click()
|
||||||
|
|
||||||
# Should show plural form
|
# 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
|
test "shows 0 members for custom field without values", %{conn: conn} do
|
||||||
{:ok, _custom_field} = create_custom_field("test_field", :string)
|
{: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
|
view
|
||||||
|> element("a", "Delete")
|
|> element("#custom-fields-component a", "Delete")
|
||||||
|> render_click()
|
|> render_click()
|
||||||
|
|
||||||
# Should show 0 members
|
# Should show 0 members
|
||||||
|
|
@ -93,15 +94,16 @@ defmodule MvWeb.CustomFieldLive.DeletionTest do
|
||||||
test "updates confirmation state when typing", %{conn: conn} do
|
test "updates confirmation state when typing", %{conn: conn} do
|
||||||
{:ok, custom_field} = create_custom_field("test_field", :string)
|
{: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
|
view
|
||||||
|> element("a", "Delete")
|
|> element("#custom-fields-component a", "Delete")
|
||||||
|> render_click()
|
|> render_click()
|
||||||
|
|
||||||
# Type in slug input
|
# Type in slug input - use element to find the form with phx-target
|
||||||
view
|
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)
|
# Confirm button should be enabled now (no disabled attribute)
|
||||||
html = render(view)
|
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
|
test "delete button is disabled when slug doesn't match", %{conn: conn} do
|
||||||
{:ok, _custom_field} = create_custom_field("test_field", :string)
|
{: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
|
view
|
||||||
|> element("a", "Delete")
|
|> element("#custom-fields-component a", "Delete")
|
||||||
|> render_click()
|
|> render_click()
|
||||||
|
|
||||||
# Type wrong slug
|
# Type wrong slug - use element to find the form with phx-target
|
||||||
view
|
view
|
||||||
|> render_change("update_slug_confirmation", %{"slug" => "wrong-slug"})
|
|> element("#delete-custom-field-modal form")
|
||||||
|
|> render_change(%{"slug" => "wrong-slug"})
|
||||||
|
|
||||||
# Button should be disabled
|
# Button should be disabled
|
||||||
html = render(view)
|
html = render(view)
|
||||||
|
|
@ -133,20 +136,21 @@ defmodule MvWeb.CustomFieldLive.DeletionTest do
|
||||||
{:ok, custom_field} = create_custom_field("test_field", :string)
|
{:ok, custom_field} = create_custom_field("test_field", :string)
|
||||||
{:ok, custom_field_value} = create_custom_field_value(member, custom_field, "test")
|
{: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
|
# Open modal
|
||||||
view
|
view
|
||||||
|> element("a", "Delete")
|
|> element("#custom-fields-component a", "Delete")
|
||||||
|> render_click()
|
|> render_click()
|
||||||
|
|
||||||
# Enter correct slug
|
# Enter correct slug - use element to find the form with phx-target
|
||||||
view
|
view
|
||||||
|> render_change("update_slug_confirmation", %{"slug" => custom_field.slug})
|
|> element("#delete-custom-field-modal form")
|
||||||
|
|> render_change(%{"slug" => custom_field.slug})
|
||||||
|
|
||||||
# Click confirm
|
# Click confirm
|
||||||
view
|
view
|
||||||
|> element("button", "Delete Custom Field and All Values")
|
|> element("#delete-custom-field-modal button", "Delete Custom Field and All Values")
|
||||||
|> render_click()
|
|> render_click()
|
||||||
|
|
||||||
# Should show success message
|
# Should show success message
|
||||||
|
|
@ -162,27 +166,28 @@ defmodule MvWeb.CustomFieldLive.DeletionTest do
|
||||||
assert {:ok, _} = Ash.get(Member, member.id)
|
assert {:ok, _} = Ash.get(Member, member.id)
|
||||||
end
|
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, custom_field} = create_custom_field("test_field", :string)
|
||||||
|
|
||||||
{:ok, view, _html} = live(conn, ~p"/custom_fields")
|
{:ok, view, _html} = live(conn, ~p"/settings")
|
||||||
|
|
||||||
view
|
view
|
||||||
|> element("a", "Delete")
|
|> element("#custom-fields-component a", "Delete")
|
||||||
|> render_click()
|
|> render_click()
|
||||||
|
|
||||||
# Enter wrong slug
|
# Enter wrong slug - use element to find the form with phx-target
|
||||||
view
|
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)
|
# Button should be disabled and we cannot click it
|
||||||
view
|
# The test verifies that the button is properly disabled in the UI
|
||||||
|> render_click("confirm_delete", %{})
|
html = render(view)
|
||||||
|
assert html =~ ~r/disabled(?:=""|(?!\w))/
|
||||||
|
|
||||||
# Should show error message
|
# Custom field should still exist since deletion couldn't proceed
|
||||||
assert render(view) =~ "Slug does not match"
|
|
||||||
|
|
||||||
# Custom field should still exist
|
|
||||||
assert {:ok, _} = Ash.get(CustomField, custom_field.id)
|
assert {:ok, _} = Ash.get(CustomField, custom_field.id)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
@ -191,10 +196,10 @@ defmodule MvWeb.CustomFieldLive.DeletionTest do
|
||||||
test "closes modal without deleting", %{conn: conn} do
|
test "closes modal without deleting", %{conn: conn} do
|
||||||
{:ok, custom_field} = create_custom_field("test_field", :string)
|
{: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
|
view
|
||||||
|> element("a", "Delete")
|
|> element("#custom-fields-component a", "Delete")
|
||||||
|> render_click()
|
|> render_click()
|
||||||
|
|
||||||
# Modal should be visible
|
# Modal should be visible
|
||||||
|
|
@ -202,7 +207,7 @@ defmodule MvWeb.CustomFieldLive.DeletionTest do
|
||||||
|
|
||||||
# Click cancel
|
# Click cancel
|
||||||
view
|
view
|
||||||
|> element("button", "Cancel")
|
|> element("#delete-custom-field-modal button", "Cancel")
|
||||||
|> render_click()
|
|> render_click()
|
||||||
|
|
||||||
# Modal should be gone
|
# Modal should be gone
|
||||||
|
|
|
||||||
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
|
# Verify we're on the correct profile page with OIDC specific information
|
||||||
{:ok, _profile_view, html} = live(conn, "/users/#{user.id}")
|
{:ok, _profile_view, html} = live(conn, "/users/#{user.id}")
|
||||||
assert html =~ to_string(user.email)
|
assert html =~ to_string(user.email)
|
||||||
# OIDC ID should be visible
|
|
||||||
assert html =~ "oidc_123"
|
|
||||||
# Password auth should be disabled for OIDC users
|
# Password auth should be disabled for OIDC users
|
||||||
assert html =~ "Not enabled"
|
assert html =~ "Not enabled"
|
||||||
end
|
end
|
||||||
|
|
@ -150,8 +148,6 @@ defmodule MvWeb.ProfileNavigationTest do
|
||||||
"/members/new",
|
"/members/new",
|
||||||
"/custom_field_values",
|
"/custom_field_values",
|
||||||
"/custom_field_values/new",
|
"/custom_field_values/new",
|
||||||
"/custom_fields",
|
|
||||||
"/custom_fields/new",
|
|
||||||
"/users",
|
"/users",
|
||||||
"/users/new"
|
"/users/new"
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -231,8 +231,8 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsDisplayTest do
|
||||||
conn = conn_with_oidc_user(conn)
|
conn = conn_with_oidc_user(conn)
|
||||||
{:ok, _view, html} = live(conn, "/members")
|
{:ok, _view, html} = live(conn, "/members")
|
||||||
|
|
||||||
# Date should be displayed in readable format
|
# Date should be displayed in European format (dd.mm.yyyy)
|
||||||
assert html =~ "1990" or html =~ "1990-05-15" or html =~ "15.05.1990"
|
assert html =~ "15.05.1990"
|
||||||
end
|
end
|
||||||
|
|
||||||
test "formats email custom field values correctly", %{conn: conn, member1: _member1} do
|
test "formats email custom field values correctly", %{conn: conn, member1: _member1} do
|
||||||
|
|
@ -242,7 +242,7 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsDisplayTest do
|
||||||
assert html =~ "alice.private@example.com"
|
assert html =~ "alice.private@example.com"
|
||||||
end
|
end
|
||||||
|
|
||||||
test "shows empty cell or placeholder for members without custom field values", %{
|
test "shows empty cell for members without custom field values", %{
|
||||||
conn: conn,
|
conn: conn,
|
||||||
member2: _member2,
|
member2: _member2,
|
||||||
field_show_string: field
|
field_show_string: field
|
||||||
|
|
@ -253,11 +253,14 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsDisplayTest do
|
||||||
# The custom field column should exist
|
# The custom field column should exist
|
||||||
assert html =~ field.name
|
assert html =~ field.name
|
||||||
|
|
||||||
# Member2 should have an empty cell for this field
|
# Member2 should exist in the table (first_name and last_name are in separate columns)
|
||||||
# We check that member2's row exists but doesn't have the value
|
assert html =~ "Bob"
|
||||||
assert html =~ "Bob Brown"
|
assert html =~ "Brown"
|
||||||
# The value should not appear for member2 (only for member1)
|
|
||||||
# We check that the value appears somewhere (for member1) but member2 row should have "-"
|
# The value from member1 should appear (phone number)
|
||||||
assert html =~ "+49123456789"
|
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
|
||||||
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
|
||||||
|
|
@ -51,7 +51,7 @@ defmodule MvWeb.MemberLive.IndexTest do
|
||||||
|> render_submit()
|
|> render_submit()
|
||||||
|> follow_redirect(conn, "/members")
|
|> follow_redirect(conn, "/members")
|
||||||
|
|
||||||
assert has_element?(index_view, "#flash-group", "Mitglied erstellt erfolgreich")
|
assert has_element?(index_view, "#flash-group", "Mitglied wurde erfolgreich erstellt")
|
||||||
end
|
end
|
||||||
|
|
||||||
test "shows translated flash message after creating a member in English", %{conn: conn} do
|
test "shows translated flash message after creating a member in English", %{conn: conn} do
|
||||||
|
|
@ -71,7 +71,7 @@ defmodule MvWeb.MemberLive.IndexTest do
|
||||||
|> render_submit()
|
|> render_submit()
|
||||||
|> follow_redirect(conn, "/members")
|
|> follow_redirect(conn, "/members")
|
||||||
|
|
||||||
assert has_element?(index_view, "#flash-group", "Member create successfully")
|
assert has_element?(index_view, "#flash-group", "Member created successfully")
|
||||||
end
|
end
|
||||||
|
|
||||||
describe "sorting integration" do
|
describe "sorting integration" do
|
||||||
|
|
@ -285,14 +285,9 @@ defmodule MvWeb.MemberLive.IndexTest do
|
||||||
conn = conn_with_oidc_user(conn)
|
conn = conn_with_oidc_user(conn)
|
||||||
{:ok, view, _html} = live(conn, "/members")
|
{:ok, view, _html} = live(conn, "/members")
|
||||||
|
|
||||||
# Select two members
|
# Select two members by sending the select_member event directly
|
||||||
view
|
render_click(view, "select_member", %{"id" => member1.id})
|
||||||
|> element("[phx-click='select_member'][phx-value-id='#{member1.id}']")
|
render_click(view, "select_member", %{"id" => member2.id})
|
||||||
|> render_click()
|
|
||||||
|
|
||||||
view
|
|
||||||
|> element("[phx-click='select_member'][phx-value-id='#{member2.id}']")
|
|
||||||
|> render_click()
|
|
||||||
|
|
||||||
# Trigger copy_emails event
|
# Trigger copy_emails event
|
||||||
view |> element("#copy-emails-btn") |> render_click()
|
view |> element("#copy-emails-btn") |> render_click()
|
||||||
|
|
@ -336,10 +331,8 @@ defmodule MvWeb.MemberLive.IndexTest do
|
||||||
conn = conn_with_oidc_user(conn)
|
conn = conn_with_oidc_user(conn)
|
||||||
{:ok, view, _html} = live(conn, "/members")
|
{:ok, view, _html} = live(conn, "/members")
|
||||||
|
|
||||||
# Select member with umlauts
|
# Select member with umlauts by sending the select_member event directly
|
||||||
view
|
render_click(view, "select_member", %{"id" => member3.id})
|
||||||
|> element("[phx-click='select_member'][phx-value-id='#{member3.id}']")
|
|
||||||
|> render_click()
|
|
||||||
|
|
||||||
# Trigger copy_emails event - should not crash
|
# Trigger copy_emails event - should not crash
|
||||||
view |> element("#copy-emails-btn") |> render_click()
|
view |> element("#copy-emails-btn") |> render_click()
|
||||||
|
|
@ -355,10 +348,8 @@ defmodule MvWeb.MemberLive.IndexTest do
|
||||||
conn = conn_with_oidc_user(conn)
|
conn = conn_with_oidc_user(conn)
|
||||||
{:ok, view, _html} = live(conn, "/members")
|
{:ok, view, _html} = live(conn, "/members")
|
||||||
|
|
||||||
# Select a member
|
# Select a member by sending the select_member event directly
|
||||||
view
|
render_click(view, "select_member", %{"id" => member1.id})
|
||||||
|> element("[phx-click='select_member'][phx-value-id='#{member1.id}']")
|
|
||||||
|> render_click()
|
|
||||||
|
|
||||||
# Delete the member from the database
|
# Delete the member from the database
|
||||||
Ash.destroy!(member1)
|
Ash.destroy!(member1)
|
||||||
|
|
@ -379,14 +370,9 @@ defmodule MvWeb.MemberLive.IndexTest do
|
||||||
conn = conn_with_oidc_user(conn)
|
conn = conn_with_oidc_user(conn)
|
||||||
{:ok, view, _html} = live(conn, "/members")
|
{:ok, view, _html} = live(conn, "/members")
|
||||||
|
|
||||||
# Select two members
|
# Select two members by sending the select_member event directly
|
||||||
view
|
render_click(view, "select_member", %{"id" => member1.id})
|
||||||
|> element("[phx-click='select_member'][phx-value-id='#{member1.id}']")
|
render_click(view, "select_member", %{"id" => member2.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
|
# Get the socket state to verify the formatted email string
|
||||||
state = :sys.get_state(view.pid)
|
state = :sys.get_state(view.pid)
|
||||||
|
|
@ -415,10 +401,8 @@ defmodule MvWeb.MemberLive.IndexTest do
|
||||||
|
|
||||||
{:ok, view, _html} = live(conn, "/members")
|
{:ok, view, _html} = live(conn, "/members")
|
||||||
|
|
||||||
# Select the test member
|
# Select the test member by sending the select_member event directly
|
||||||
view
|
render_click(view, "select_member", %{"id" => test_member.id})
|
||||||
|> element("[phx-click='select_member'][phx-value-id='#{test_member.id}']")
|
|
||||||
|> render_click()
|
|
||||||
|
|
||||||
# The format should be "Test Format <test.format@example.com>"
|
# The format should be "Test Format <test.format@example.com>"
|
||||||
# We verify this by checking the flash shows 1 email was copied
|
# We verify this by checking the flash shows 1 email was copied
|
||||||
|
|
@ -441,10 +425,8 @@ defmodule MvWeb.MemberLive.IndexTest do
|
||||||
conn = conn_with_oidc_user(conn)
|
conn = conn_with_oidc_user(conn)
|
||||||
{:ok, view, _html} = live(conn, "/members")
|
{:ok, view, _html} = live(conn, "/members")
|
||||||
|
|
||||||
# Select a member
|
# Select a member by sending the select_member event directly
|
||||||
view
|
render_click(view, "select_member", %{"id" => member1.id})
|
||||||
|> element("[phx-click='select_member'][phx-value-id='#{member1.id}']")
|
|
||||||
|> render_click()
|
|
||||||
|
|
||||||
# Button should now be visible
|
# Button should now be visible
|
||||||
assert has_element?(view, "#copy-emails-btn")
|
assert has_element?(view, "#copy-emails-btn")
|
||||||
|
|
@ -457,10 +439,8 @@ defmodule MvWeb.MemberLive.IndexTest do
|
||||||
conn = conn_with_oidc_user(conn)
|
conn = conn_with_oidc_user(conn)
|
||||||
{:ok, view, _html} = live(conn, "/members")
|
{:ok, view, _html} = live(conn, "/members")
|
||||||
|
|
||||||
# Select a member
|
# Select a member by sending the select_member event directly
|
||||||
view
|
render_click(view, "select_member", %{"id" => member1.id})
|
||||||
|> element("[phx-click='select_member'][phx-value-id='#{member1.id}']")
|
|
||||||
|> render_click()
|
|
||||||
|
|
||||||
# Click copy button
|
# Click copy button
|
||||||
view |> element("#copy-emails-btn") |> render_click()
|
view |> element("#copy-emails-btn") |> render_click()
|
||||||
|
|
|
||||||
|
|
@ -33,8 +33,6 @@ defmodule MvWeb.UserLive.IndexTest do
|
||||||
|
|
||||||
assert html =~ "alice@example.com"
|
assert html =~ "alice@example.com"
|
||||||
assert html =~ "bob@example.com"
|
assert html =~ "bob@example.com"
|
||||||
assert html =~ "alice123"
|
|
||||||
assert html =~ "bob456"
|
|
||||||
end
|
end
|
||||||
|
|
||||||
test "shows correct action links", %{conn: conn} do
|
test "shows correct action links", %{conn: conn} do
|
||||||
|
|
@ -386,10 +384,6 @@ defmodule MvWeb.UserLive.IndexTest do
|
||||||
|
|
||||||
# Should still show the table structure
|
# Should still show the table structure
|
||||||
assert html =~ "Email"
|
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
|
end
|
||||||
|
|
||||||
test "handles users with missing OIDC ID", %{conn: conn} do
|
test "handles users with missing OIDC ID", %{conn: conn} do
|
||||||
|
|
|
||||||
|
|
@ -11,9 +11,9 @@ defmodule Mv.SeedsTest do
|
||||||
{:ok, members} = Ash.read(Mv.Membership.Member)
|
{:ok, members} = Ash.read(Mv.Membership.Member)
|
||||||
{:ok, custom_fields} = Ash.read(Mv.Membership.CustomField)
|
{:ok, custom_fields} = Ash.read(Mv.Membership.CustomField)
|
||||||
|
|
||||||
assert length(users) > 0, "Seeds should create at least one user"
|
assert not Enum.empty?(users), "Seeds should create at least one user"
|
||||||
assert length(members) > 0, "Seeds should create at least one member"
|
assert not Enum.empty?(members), "Seeds should create at least one member"
|
||||||
assert length(custom_fields) > 0, "Seeds should create at least one custom field"
|
assert not Enum.empty?(custom_fields), "Seeds should create at least one custom field"
|
||||||
end
|
end
|
||||||
|
|
||||||
test "can be run multiple times (idempotent)" do
|
test "can be run multiple times (idempotent)" do
|
||||||
|
|
|
||||||
|
|
@ -123,7 +123,13 @@ defmodule MvWeb.ConnCase do
|
||||||
end
|
end
|
||||||
|
|
||||||
setup tags do
|
setup tags do
|
||||||
Mv.DataCase.setup_sandbox(tags)
|
pid = Mv.DataCase.setup_sandbox(tags)
|
||||||
{:ok, conn: Phoenix.ConnTest.build_conn()}
|
|
||||||
|
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
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -34,10 +34,12 @@ defmodule Mv.DataCase do
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Sets up the sandbox based on the test tags.
|
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
|
def setup_sandbox(tags) do
|
||||||
pid = Ecto.Adapters.SQL.Sandbox.start_owner!(Mv.Repo, shared: not tags[:async])
|
pid = Ecto.Adapters.SQL.Sandbox.start_owner!(Mv.Repo, shared: not tags[:async])
|
||||||
on_exit(fn -> Ecto.Adapters.SQL.Sandbox.stop_owner(pid) end)
|
on_exit(fn -> Ecto.Adapters.SQL.Sandbox.stop_owner(pid) end)
|
||||||
|
pid
|
||||||
end
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue