Compare commits
35 commits
51d7f9d5b3
...
17ddca8cdb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
17ddca8cdb | ||
| 702eebd110 | |||
| 5ae4450444 | |||
| cf6a108049 | |||
| fabfe64468 | |||
| 6029920c3f | |||
| 6cf955b024 | |||
| 217ed632fa | |||
| 3b038d451d | |||
| ecc6522571 | |||
| b9bd5882e7 | |||
| 690083bdf0 | |||
| 4bbba65038 | |||
| 75e1fc8a3a | |||
| a68a15be6a | |||
| 8ce89a7227 | |||
| f5b67de870 | |||
| 188a6f667c | |||
| ba5fc34d80 | |||
| a92771ffca | |||
| c17445975c | |||
| c9678231f9 | |||
| c3b33b55a5 | |||
| 8d1d04fa05 | |||
| 064c0df701 | |||
| f0613fe1e5 | |||
| 206e733511 | |||
| 0fb43a0816 | |||
| 45a9bc0cc0 | |||
| d039e4bb7d | |||
| 7f0da693ee | |||
| 82e41916d2 | |||
| a022d8cd02 | |||
| f24d4985fc | |||
| cf957563bb |
30 changed files with 3170 additions and 620 deletions
|
|
@ -166,7 +166,7 @@ environment:
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: renovate
|
- name: renovate
|
||||||
image: renovate/renovate:41.173
|
image: renovate/renovate:42.33
|
||||||
environment:
|
environment:
|
||||||
RENOVATE_CONFIG_FILE: "renovate_backend_config.js"
|
RENOVATE_CONFIG_FILE: "renovate_backend_config.js"
|
||||||
RENOVATE_TOKEN:
|
RENOVATE_TOKEN:
|
||||||
|
|
|
||||||
5
Justfile
5
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,7 +93,7 @@ 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 '/^<<<<<<</d; /^=======$/d; /^>>>>>>>/d; /^%%%%%%%/d; /^++++++/d; s/^+//' {} \;
|
find priv/gettext -type f -exec sed -i '/^<<<<<<</d; /^=======$/d; /^>>>>>>>/d; /^%%%%%%%/d; /^++++++/d; s/^+//; s/^-//' {} \;
|
||||||
|
|
||||||
# Production environment commands
|
# Production environment commands
|
||||||
# ================================
|
# ================================
|
||||||
|
|
|
||||||
|
|
@ -401,6 +401,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.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -119,6 +119,123 @@ 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 btn-ghost"
|
||||||
|
phx-click="toggle_dropdown"
|
||||||
|
phx-target={@phx_target}
|
||||||
|
data-testid="dropdown-button"
|
||||||
|
>
|
||||||
|
<%= if @icon do %>
|
||||||
|
<.icon name={@icon} />
|
||||||
|
<% end %>
|
||||||
|
<span>{@button_label}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<ul
|
||||||
|
:if={@open}
|
||||||
|
id={@id}
|
||||||
|
role="menu"
|
||||||
|
class="absolute right-0 mt-2 bg-base-100 z-[100] p-2 shadow-lg rounded-box w-64 max-h-96 overflow-y-auto border border-base-300"
|
||||||
|
tabindex="0"
|
||||||
|
phx-target={@phx_target}
|
||||||
|
>
|
||||||
|
<li :if={@show_select_buttons} role="none">
|
||||||
|
<div class="flex justify-between items-center mb-2 px-2">
|
||||||
|
<span class="font-semibold">{gettext("Options")}</span>
|
||||||
|
<div class="flex gap-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="menuitem"
|
||||||
|
aria-label={gettext("Select all")}
|
||||||
|
phx-click="select_all"
|
||||||
|
phx-target={@phx_target}
|
||||||
|
class="btn btn-xs btn-ghost"
|
||||||
|
>
|
||||||
|
{gettext("All")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="menuitem"
|
||||||
|
aria-label={gettext("Select none")}
|
||||||
|
phx-click="select_none"
|
||||||
|
phx-target={@phx_target}
|
||||||
|
class="btn btn-xs btn-ghost"
|
||||||
|
>
|
||||||
|
{gettext("None")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li :if={@show_select_buttons} role="separator" class="divider my-1"></li>
|
||||||
|
|
||||||
|
<%= for item <- @items do %>
|
||||||
|
<li role="none">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role={if @checkboxes, do: "menuitemcheckbox", else: "menuitem"}
|
||||||
|
aria-checked={
|
||||||
|
if @checkboxes, do: to_string(Map.get(@selected, item.value, true)), else: nil
|
||||||
|
}
|
||||||
|
tabindex="0"
|
||||||
|
class="flex items-center gap-2 px-2 py-1 rounded cursor-pointer hover:bg-base-200 w-full text-left"
|
||||||
|
phx-click="select_item"
|
||||||
|
phx-keydown="select_item"
|
||||||
|
phx-key="Enter"
|
||||||
|
phx-value-item={item.value}
|
||||||
|
phx-target={@phx_target}
|
||||||
|
>
|
||||||
|
<%= if @checkboxes do %>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={Map.get(@selected, item.value, true)}
|
||||||
|
class="checkbox checkbox-sm checkbox-primary"
|
||||||
|
tabindex="-1"
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
<% end %>
|
||||||
|
<span>{item.label}</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
<% end %>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Renders an input with label and error messages.
|
Renders an input with label and error messages.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ 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>
|
<li>
|
||||||
<details>
|
<details>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,176 @@
|
||||||
|
defmodule MvWeb.Components.FieldVisibilityDropdownComponent do
|
||||||
|
@moduledoc """
|
||||||
|
LiveComponent for managing field visibility in the member overview.
|
||||||
|
|
||||||
|
Provides an accessible dropdown menu where users can select/deselect
|
||||||
|
which member fields and custom fields are visible in the table.
|
||||||
|
|
||||||
|
## Props
|
||||||
|
- `:all_fields` - List of all available fields
|
||||||
|
- `:custom_fields` - List of CustomField resources
|
||||||
|
- `:selected_fields` - Map field_name → boolean
|
||||||
|
- `:id` - Component ID
|
||||||
|
|
||||||
|
## Events sent to parent:
|
||||||
|
- `{:field_toggled, field, value}`
|
||||||
|
- `{:fields_selected, map}`
|
||||||
|
"""
|
||||||
|
|
||||||
|
use MvWeb, :live_component
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# UPDATE
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def update(assigns, socket) do
|
||||||
|
socket =
|
||||||
|
socket
|
||||||
|
|> assign(assigns)
|
||||||
|
|> assign_new(:open, fn -> false end)
|
||||||
|
|> assign_new(:all_fields, fn -> [] end)
|
||||||
|
|> assign_new(:custom_fields, fn -> [] end)
|
||||||
|
|> assign_new(:selected_fields, fn -> %{} end)
|
||||||
|
|
||||||
|
{:ok, socket}
|
||||||
|
end
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# RENDER
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def render(assigns) do
|
||||||
|
all_fields = assigns.all_fields || []
|
||||||
|
custom_fields = assigns.custom_fields || []
|
||||||
|
|
||||||
|
all_items =
|
||||||
|
Enum.map(extract_member_field_keys(all_fields), fn field ->
|
||||||
|
%{
|
||||||
|
value: field_to_string(field),
|
||||||
|
label: format_field_label(field)
|
||||||
|
}
|
||||||
|
end) ++
|
||||||
|
Enum.map(extract_custom_field_keys(all_fields), fn field ->
|
||||||
|
%{
|
||||||
|
value: field,
|
||||||
|
label: format_custom_field_label(field, custom_fields)
|
||||||
|
}
|
||||||
|
end)
|
||||||
|
|
||||||
|
assigns = assign(assigns, :all_items, all_items)
|
||||||
|
|
||||||
|
# LiveComponents require a static HTML element as root, not a function component
|
||||||
|
~H"""
|
||||||
|
<div>
|
||||||
|
<.dropdown_menu
|
||||||
|
id="field-visibility-menu"
|
||||||
|
icon="hero-adjustments-horizontal"
|
||||||
|
button_label={gettext("Columns")}
|
||||||
|
items={@all_items}
|
||||||
|
checkboxes={true}
|
||||||
|
selected={@selected_fields}
|
||||||
|
open={@open}
|
||||||
|
show_select_buttons={true}
|
||||||
|
phx_target={@myself}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# EVENTS (matching the Core Component API)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_event("toggle_dropdown", _params, socket) do
|
||||||
|
{:noreply, assign(socket, :open, !socket.assigns.open)}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_event("close_dropdown", _params, socket) do
|
||||||
|
{:noreply, assign(socket, :open, false)}
|
||||||
|
end
|
||||||
|
|
||||||
|
# toggle single item
|
||||||
|
def handle_event("select_item", %{"item" => item}, socket) do
|
||||||
|
current = Map.get(socket.assigns.selected_fields, item, true)
|
||||||
|
updated = Map.put(socket.assigns.selected_fields, item, !current)
|
||||||
|
|
||||||
|
send(self(), {:field_toggled, item, !current})
|
||||||
|
{:noreply, assign(socket, :selected_fields, updated)}
|
||||||
|
end
|
||||||
|
|
||||||
|
# select all
|
||||||
|
def handle_event("select_all", _params, socket) do
|
||||||
|
all =
|
||||||
|
socket.assigns.all_fields
|
||||||
|
|> Enum.map(&field_to_string/1)
|
||||||
|
|> Enum.map(&{&1, true})
|
||||||
|
|> Enum.into(%{})
|
||||||
|
|
||||||
|
send(self(), {:fields_selected, all})
|
||||||
|
{:noreply, assign(socket, :selected_fields, all)}
|
||||||
|
end
|
||||||
|
|
||||||
|
# select none
|
||||||
|
def handle_event("select_none", _params, socket) do
|
||||||
|
none =
|
||||||
|
socket.assigns.all_fields
|
||||||
|
|> Enum.map(&field_to_string/1)
|
||||||
|
|> Enum.map(&{&1, false})
|
||||||
|
|> Enum.into(%{})
|
||||||
|
|
||||||
|
send(self(), {:fields_selected, none})
|
||||||
|
{:noreply, assign(socket, :selected_fields, none)}
|
||||||
|
end
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# HELPERS (with defensive nil guards)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
defp extract_member_field_keys(nil), do: []
|
||||||
|
|
||||||
|
defp extract_member_field_keys(fields) do
|
||||||
|
prefix = Mv.Constants.custom_field_prefix()
|
||||||
|
|
||||||
|
Enum.filter(fields, fn field ->
|
||||||
|
is_atom(field) ||
|
||||||
|
(is_binary(field) && not String.starts_with?(field, prefix))
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp extract_custom_field_keys(nil), do: []
|
||||||
|
|
||||||
|
defp extract_custom_field_keys(fields) do
|
||||||
|
prefix = Mv.Constants.custom_field_prefix()
|
||||||
|
|
||||||
|
Enum.filter(fields, fn field ->
|
||||||
|
is_binary(field) && String.starts_with?(field, prefix)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp field_to_string(field) when is_atom(field), do: Atom.to_string(field)
|
||||||
|
defp field_to_string(field) when is_binary(field), do: field
|
||||||
|
|
||||||
|
defp format_field_label(field) do
|
||||||
|
field
|
||||||
|
|> field_to_string()
|
||||||
|
|> String.replace("_", " ")
|
||||||
|
|> String.split()
|
||||||
|
|> Enum.map_join(" ", &String.capitalize/1)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp format_custom_field_label(field_string, custom_fields) do
|
||||||
|
id = String.trim_leading(field_string, Mv.Constants.custom_field_prefix())
|
||||||
|
find_custom_field_name(id, field_string, custom_fields)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp find_custom_field_name("", field_string, _custom_fields), do: field_string
|
||||||
|
|
||||||
|
defp find_custom_field_name(id, _field_string, custom_fields) do
|
||||||
|
case Enum.find(custom_fields, fn cf -> to_string(cf.id) == id end) do
|
||||||
|
nil -> gettext("Custom Field %{id}", id: id)
|
||||||
|
custom_field -> custom_field.name
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -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
|
|
||||||
261
lib/mv_web/live/custom_field_live/index_component.ex
Normal file
261
lib/mv_web/live/custom_field_live/index_component.ex
Normal file
|
|
@ -0,0 +1,261 @@
|
||||||
|
defmodule MvWeb.CustomFieldLive.IndexComponent do
|
||||||
|
@moduledoc """
|
||||||
|
LiveComponent for managing custom field definitions (embedded in settings).
|
||||||
|
|
||||||
|
## Features
|
||||||
|
- List all custom fields
|
||||||
|
- Display type information (name, value type, description)
|
||||||
|
- Show immutable and required flags
|
||||||
|
- Create new custom fields
|
||||||
|
- Edit existing custom fields
|
||||||
|
- Delete custom fields with confirmation (cascades to all custom field values)
|
||||||
|
"""
|
||||||
|
use MvWeb, :live_component
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def render(assigns) do
|
||||||
|
~H"""
|
||||||
|
<div id={@id}>
|
||||||
|
<.header>
|
||||||
|
{gettext("Custom Fields")}
|
||||||
|
<:subtitle>
|
||||||
|
{gettext("These will appear in addition to other data when adding new members.")}
|
||||||
|
</:subtitle>
|
||||||
|
<:actions>
|
||||||
|
<.button variant="primary" phx-click="new_custom_field" phx-target={@myself}>
|
||||||
|
<.icon name="hero-plus" /> {gettext("New Custom field")}
|
||||||
|
</.button>
|
||||||
|
</:actions>
|
||||||
|
</.header>
|
||||||
|
|
||||||
|
<%!-- Show form when creating or editing --%>
|
||||||
|
<div :if={@show_form} class="mb-8">
|
||||||
|
<.live_component
|
||||||
|
module={MvWeb.CustomFieldLive.FormComponent}
|
||||||
|
id={@form_id}
|
||||||
|
custom_field={@editing_custom_field}
|
||||||
|
on_save={
|
||||||
|
fn custom_field, action -> send(self(), {:custom_field_saved, custom_field, action}) end
|
||||||
|
}
|
||||||
|
on_cancel={fn -> send_update(__MODULE__, id: @id, show_form: false) end}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%!-- Hide table when form is visible --%>
|
||||||
|
<.table
|
||||||
|
:if={!@show_form}
|
||||||
|
id="custom_fields"
|
||||||
|
rows={@streams.custom_fields}
|
||||||
|
row_click={
|
||||||
|
fn {_id, custom_field} ->
|
||||||
|
JS.push("edit_custom_field", value: %{id: custom_field.id}, target: @myself)
|
||||||
|
end
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<:col :let={{_id, custom_field}} label={gettext("Name")}>{custom_field.name}</:col>
|
||||||
|
|
||||||
|
<:col :let={{_id, custom_field}} label={gettext("Value Type")}>
|
||||||
|
{custom_field.value_type}
|
||||||
|
</:col>
|
||||||
|
|
||||||
|
<:col :let={{_id, custom_field}} label={gettext("Description")}>
|
||||||
|
{custom_field.description}
|
||||||
|
</:col>
|
||||||
|
|
||||||
|
<:col :let={{_id, custom_field}} label={gettext("Show in Overview")}>
|
||||||
|
<span :if={custom_field.show_in_overview} class="badge badge-success">
|
||||||
|
{gettext("Yes")}
|
||||||
|
</span>
|
||||||
|
<span :if={!custom_field.show_in_overview} class="badge badge-ghost">
|
||||||
|
{gettext("No")}
|
||||||
|
</span>
|
||||||
|
</:col>
|
||||||
|
|
||||||
|
<:action :let={{_id, custom_field}}>
|
||||||
|
<.link phx-click={
|
||||||
|
JS.push("edit_custom_field", value: %{id: custom_field.id}, target: @myself)
|
||||||
|
}>
|
||||||
|
{gettext("Edit")}
|
||||||
|
</.link>
|
||||||
|
</:action>
|
||||||
|
|
||||||
|
<:action :let={{_id, custom_field}}>
|
||||||
|
<.link phx-click={JS.push("prepare_delete", value: %{id: custom_field.id}, target: @myself)}>
|
||||||
|
{gettext("Delete")}
|
||||||
|
</.link>
|
||||||
|
</:action>
|
||||||
|
</.table>
|
||||||
|
|
||||||
|
<%!-- Delete Confirmation Modal --%>
|
||||||
|
<dialog :if={@show_delete_modal} id="delete-custom-field-modal" class="modal modal-open">
|
||||||
|
<div class="modal-box">
|
||||||
|
<h3 class="text-lg font-bold">{gettext("Delete Custom Field")}</h3>
|
||||||
|
|
||||||
|
<div class="py-4 space-y-4">
|
||||||
|
<div class="alert alert-warning">
|
||||||
|
<.icon name="hero-exclamation-triangle" class="w-5 h-5" />
|
||||||
|
<div>
|
||||||
|
<p class="font-semibold">
|
||||||
|
{ngettext(
|
||||||
|
"%{count} member has a value assigned for this custom field.",
|
||||||
|
"%{count} members have values assigned for this custom field.",
|
||||||
|
@custom_field_to_delete.assigned_members_count,
|
||||||
|
count: @custom_field_to_delete.assigned_members_count
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<p class="mt-2 text-sm">
|
||||||
|
{gettext(
|
||||||
|
"All custom field values will be permanently deleted when you delete this custom field."
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label for="slug-confirmation" class="label">
|
||||||
|
<span class="label-text">
|
||||||
|
{gettext("To confirm deletion, please enter this text:")}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<div class="p-2 mb-2 font-mono text-lg font-bold break-all rounded bg-base-200">
|
||||||
|
{@custom_field_to_delete.slug}
|
||||||
|
</div>
|
||||||
|
<form phx-change="update_slug_confirmation" phx-target={@myself}>
|
||||||
|
<input
|
||||||
|
id="slug-confirmation"
|
||||||
|
name="slug"
|
||||||
|
type="text"
|
||||||
|
value={@slug_confirmation}
|
||||||
|
placeholder={gettext("Enter the text above to confirm")}
|
||||||
|
autocomplete="off"
|
||||||
|
phx-mounted={JS.focus()}
|
||||||
|
class="w-full input input-bordered"
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-action">
|
||||||
|
<button phx-click="cancel_delete" phx-target={@myself} class="btn">
|
||||||
|
{gettext("Cancel")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
phx-click="confirm_delete"
|
||||||
|
phx-target={@myself}
|
||||||
|
class="btn btn-error"
|
||||||
|
disabled={@slug_confirmation != @custom_field_to_delete.slug}
|
||||||
|
>
|
||||||
|
{gettext("Delete Custom Field and All Values")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def update(assigns, socket) do
|
||||||
|
# If show_form is explicitly provided in assigns, reset editing state
|
||||||
|
socket =
|
||||||
|
if Map.has_key?(assigns, :show_form) and assigns.show_form == false do
|
||||||
|
socket
|
||||||
|
|> assign(:editing_custom_field, nil)
|
||||||
|
|> assign(:form_id, "custom-field-form-new")
|
||||||
|
else
|
||||||
|
socket
|
||||||
|
end
|
||||||
|
|
||||||
|
{:ok,
|
||||||
|
socket
|
||||||
|
|> assign(assigns)
|
||||||
|
|> assign_new(:show_form, fn -> false end)
|
||||||
|
|> assign_new(:form_id, fn -> "custom-field-form-new" end)
|
||||||
|
|> assign_new(:editing_custom_field, fn -> nil end)
|
||||||
|
|> assign_new(:show_delete_modal, fn -> false end)
|
||||||
|
|> assign_new(:custom_field_to_delete, fn -> nil end)
|
||||||
|
|> assign_new(:slug_confirmation, fn -> "" end)
|
||||||
|
|> stream(:custom_fields, Ash.read!(Mv.Membership.CustomField), reset: true)}
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_event("new_custom_field", _params, socket) do
|
||||||
|
{:noreply,
|
||||||
|
socket
|
||||||
|
|> assign(:show_form, true)
|
||||||
|
|> assign(:editing_custom_field, nil)
|
||||||
|
|> assign(:form_id, "custom-field-form-new")}
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_event("edit_custom_field", %{"id" => id}, socket) do
|
||||||
|
custom_field = Ash.get!(Mv.Membership.CustomField, id)
|
||||||
|
|
||||||
|
{:noreply,
|
||||||
|
socket
|
||||||
|
|> assign(:show_form, true)
|
||||||
|
|> assign(:editing_custom_field, custom_field)
|
||||||
|
|> assign(:form_id, "custom-field-form-#{id}")}
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_event("prepare_delete", %{"id" => id}, socket) do
|
||||||
|
custom_field = Ash.get!(Mv.Membership.CustomField, id, load: [:assigned_members_count])
|
||||||
|
|
||||||
|
{:noreply,
|
||||||
|
socket
|
||||||
|
|> assign(:custom_field_to_delete, custom_field)
|
||||||
|
|> assign(:show_delete_modal, true)
|
||||||
|
|> assign(:slug_confirmation, "")}
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_event("update_slug_confirmation", %{"slug" => slug}, socket) do
|
||||||
|
{:noreply, assign(socket, :slug_confirmation, slug)}
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_event("confirm_delete", _params, socket) do
|
||||||
|
custom_field = socket.assigns.custom_field_to_delete
|
||||||
|
|
||||||
|
if socket.assigns.slug_confirmation == custom_field.slug do
|
||||||
|
case Ash.destroy(custom_field) do
|
||||||
|
:ok ->
|
||||||
|
send(self(), {:custom_field_deleted, custom_field})
|
||||||
|
|
||||||
|
{:noreply,
|
||||||
|
socket
|
||||||
|
|> assign(:show_delete_modal, false)
|
||||||
|
|> assign(:custom_field_to_delete, nil)
|
||||||
|
|> assign(:slug_confirmation, "")
|
||||||
|
|> stream_delete(:custom_fields, custom_field)}
|
||||||
|
|
||||||
|
{:error, error} ->
|
||||||
|
send(self(), {:custom_field_delete_error, error})
|
||||||
|
|
||||||
|
{:noreply,
|
||||||
|
socket
|
||||||
|
|> assign(:show_delete_modal, false)
|
||||||
|
|> assign(:custom_field_to_delete, nil)
|
||||||
|
|> assign(:slug_confirmation, "")}
|
||||||
|
end
|
||||||
|
else
|
||||||
|
send(self(), :custom_field_slug_mismatch)
|
||||||
|
|
||||||
|
{:noreply,
|
||||||
|
socket
|
||||||
|
|> assign(:show_delete_modal, false)
|
||||||
|
|> assign(:custom_field_to_delete, nil)
|
||||||
|
|> assign(:slug_confirmation, "")}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_event("cancel_delete", _params, socket) do
|
||||||
|
{:noreply,
|
||||||
|
socket
|
||||||
|
|> assign(:show_delete_modal, false)
|
||||||
|
|> assign(:custom_field_to_delete, nil)
|
||||||
|
|> assign(:slug_confirmation, "")}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -1,75 +0,0 @@
|
||||||
defmodule MvWeb.CustomFieldLive.Show do
|
|
||||||
@moduledoc """
|
|
||||||
LiveView for displaying a single custom field's details (admin).
|
|
||||||
|
|
||||||
## Features
|
|
||||||
- Display custom field definition
|
|
||||||
- Show all attributes (name, value type, description, flags)
|
|
||||||
- Navigate to edit form
|
|
||||||
- Return to custom field list
|
|
||||||
|
|
||||||
## Displayed Information
|
|
||||||
- ID: Internal UUID identifier
|
|
||||||
- Slug: URL-friendly identifier (auto-generated, immutable)
|
|
||||||
- Name: Unique identifier
|
|
||||||
- Value type: Data type constraint
|
|
||||||
- Description: Optional explanation
|
|
||||||
- Immutable flag: Whether values can be changed
|
|
||||||
- Required flag: Whether all members need this custom field
|
|
||||||
|
|
||||||
## Navigation
|
|
||||||
- Back to custom field list
|
|
||||||
- Edit custom field
|
|
||||||
|
|
||||||
## Security
|
|
||||||
Custom field details are restricted to admin users.
|
|
||||||
"""
|
|
||||||
use MvWeb, :live_view
|
|
||||||
|
|
||||||
@impl true
|
|
||||||
def render(assigns) do
|
|
||||||
~H"""
|
|
||||||
<Layouts.app flash={@flash} current_user={@current_user}>
|
|
||||||
<.header>
|
|
||||||
Custom field {@custom_field.slug}
|
|
||||||
<:subtitle>This is a custom_field record from your database.</:subtitle>
|
|
||||||
|
|
||||||
<:actions>
|
|
||||||
<.button navigate={~p"/custom_fields"}>
|
|
||||||
<.icon name="hero-arrow-left" />
|
|
||||||
</.button>
|
|
||||||
<.button
|
|
||||||
variant="primary"
|
|
||||||
navigate={~p"/custom_fields/#{@custom_field}/edit?return_to=show"}
|
|
||||||
>
|
|
||||||
<.icon name="hero-pencil-square" /> Edit Custom field
|
|
||||||
</.button>
|
|
||||||
</:actions>
|
|
||||||
</.header>
|
|
||||||
|
|
||||||
<.list>
|
|
||||||
<:item title="Id">{@custom_field.id}</:item>
|
|
||||||
|
|
||||||
<:item title="Slug">
|
|
||||||
{@custom_field.slug}
|
|
||||||
<p class="mt-2 text-sm leading-6 text-zinc-600">
|
|
||||||
{gettext("Auto-generated identifier (immutable)")}
|
|
||||||
</p>
|
|
||||||
</:item>
|
|
||||||
|
|
||||||
<:item title="Name">{@custom_field.name}</:item>
|
|
||||||
|
|
||||||
<:item title="Description">{@custom_field.description}</:item>
|
|
||||||
</.list>
|
|
||||||
</Layouts.app>
|
|
||||||
"""
|
|
||||||
end
|
|
||||||
|
|
||||||
@impl true
|
|
||||||
def mount(%{"id" => id}, _session, socket) do
|
|
||||||
{:ok,
|
|
||||||
socket
|
|
||||||
|> assign(:page_title, "Show Custom field")
|
|
||||||
|> assign(:custom_field, Ash.get!(Mv.Membership.CustomField, id))}
|
|
||||||
end
|
|
||||||
end
|
|
||||||
|
|
@ -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,12 +39,16 @@ 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>
|
||||||
|
|
||||||
|
<%!-- Club Settings Section --%>
|
||||||
|
<.header>
|
||||||
|
{gettext("Club Settings")}
|
||||||
|
</.header>
|
||||||
<.form for={@form} id="settings-form" phx-change="validate" phx-submit="save">
|
<.form for={@form} id="settings-form" phx-change="validate" phx-submit="save">
|
||||||
<.input
|
<.input
|
||||||
field={@form[:club_name]}
|
field={@form[:club_name]}
|
||||||
|
|
@ -56,6 +61,12 @@ defmodule MvWeb.GlobalSettingsLive do
|
||||||
{gettext("Save Settings")}
|
{gettext("Save Settings")}
|
||||||
</.button>
|
</.button>
|
||||||
</.form>
|
</.form>
|
||||||
|
|
||||||
|
<%!-- 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(
|
||||||
|
|
|
||||||
|
|
@ -33,9 +33,11 @@ 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.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
|
||||||
|
|
@ -50,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.
|
||||||
|
|
@ -61,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
|
||||||
|
|
@ -69,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"))
|
||||||
|
|
@ -77,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}
|
||||||
|
|
@ -183,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
|
||||||
|
|
@ -251,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()
|
||||||
|
|
||||||
|
|
@ -281,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 ->
|
||||||
|
|
@ -377,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
|
||||||
|
|
@ -435,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)
|
||||||
|
|
@ -615,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:
|
||||||
|
|
@ -911,34 +1100,6 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# Gets the list of member fields that should be visible in the overview.
|
|
||||||
#
|
|
||||||
# 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
|
|
||||||
|
|
||||||
# Public helper function to format dates for use in templates
|
# Public helper function to format dates for use in templates
|
||||||
def format_date(date), do: DateFormatter.format_date(date)
|
def format_date(date), do: DateFormatter.format_date(date)
|
||||||
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
|
||||||
|
|
@ -85,6 +92,7 @@
|
||||||
</: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 +106,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}
|
||||||
|
|
@ -226,7 +252,7 @@
|
||||||
>
|
>
|
||||||
{MvWeb.MemberLive.Index.format_date(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
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,7 @@ msgid "City"
|
||||||
msgstr "Stadt"
|
msgstr "Stadt"
|
||||||
|
|
||||||
#: lib/mv_web/live/contribution_type_live/index.ex
|
#: lib/mv_web/live/contribution_type_live/index.ex
|
||||||
|
#: lib/mv_web/live/custom_field_live/index_component.ex
|
||||||
#: lib/mv_web/live/member_live/index.html.heex
|
#: lib/mv_web/live/member_live/index.html.heex
|
||||||
#: lib/mv_web/live/user_live/index.html.heex
|
#: lib/mv_web/live/user_live/index.html.heex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
|
|
@ -41,6 +42,7 @@ msgid "Delete"
|
||||||
msgstr "Löschen"
|
msgstr "Löschen"
|
||||||
|
|
||||||
#: lib/mv_web/live/contribution_type_live/index.ex
|
#: lib/mv_web/live/contribution_type_live/index.ex
|
||||||
|
#: lib/mv_web/live/custom_field_live/index_component.ex
|
||||||
#: lib/mv_web/live/member_live/index.html.heex
|
#: lib/mv_web/live/member_live/index.html.heex
|
||||||
#: lib/mv_web/live/user_live/form.ex
|
#: lib/mv_web/live/user_live/form.ex
|
||||||
#: lib/mv_web/live/user_live/index.html.heex
|
#: lib/mv_web/live/user_live/index.html.heex
|
||||||
|
|
@ -154,7 +156,7 @@ msgstr "Postleitzahl"
|
||||||
msgid "Save Member"
|
msgid "Save Member"
|
||||||
msgstr "Mitglied speichern"
|
msgstr "Mitglied speichern"
|
||||||
|
|
||||||
#: lib/mv_web/live/custom_field_live/form.ex
|
#: lib/mv_web/live/custom_field_live/form_component.ex
|
||||||
#: lib/mv_web/live/custom_field_value_live/form.ex
|
#: lib/mv_web/live/custom_field_value_live/form.ex
|
||||||
#: lib/mv_web/live/global_settings_live.ex
|
#: lib/mv_web/live/global_settings_live.ex
|
||||||
#: lib/mv_web/live/member_live/form.ex
|
#: lib/mv_web/live/member_live/form.ex
|
||||||
|
|
@ -169,6 +171,7 @@ msgstr "Speichern..."
|
||||||
msgid "Street"
|
msgid "Street"
|
||||||
msgstr "Straße"
|
msgstr "Straße"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/custom_field_live/index_component.ex
|
||||||
#: lib/mv_web/live/member_live/index.html.heex
|
#: lib/mv_web/live/member_live/index.html.heex
|
||||||
#: lib/mv_web/live/member_live/index/formatter.ex
|
#: lib/mv_web/live/member_live/index/formatter.ex
|
||||||
#: lib/mv_web/live/member_live/show.ex
|
#: lib/mv_web/live/member_live/show.ex
|
||||||
|
|
@ -181,6 +184,7 @@ msgstr "Nein"
|
||||||
msgid "Show Member"
|
msgid "Show Member"
|
||||||
msgstr "Mitglied anzeigen"
|
msgstr "Mitglied anzeigen"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/custom_field_live/index_component.ex
|
||||||
#: lib/mv_web/live/member_live/index.html.heex
|
#: lib/mv_web/live/member_live/index.html.heex
|
||||||
#: lib/mv_web/live/member_live/index/formatter.ex
|
#: lib/mv_web/live/member_live/index/formatter.ex
|
||||||
#: lib/mv_web/live/member_live/show.ex
|
#: lib/mv_web/live/member_live/show.ex
|
||||||
|
|
@ -188,14 +192,14 @@ msgstr "Mitglied anzeigen"
|
||||||
msgid "Yes"
|
msgid "Yes"
|
||||||
msgstr "Ja"
|
msgstr "Ja"
|
||||||
|
|
||||||
#: lib/mv_web/live/custom_field_live/form.ex
|
#: lib/mv_web/live/custom_field_live/form_component.ex
|
||||||
#: lib/mv_web/live/custom_field_value_live/form.ex
|
#: lib/mv_web/live/custom_field_value_live/form.ex
|
||||||
#: lib/mv_web/live/member_live/form.ex
|
#: lib/mv_web/live/member_live/form.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "create"
|
msgid "create"
|
||||||
msgstr "erstellt"
|
msgstr "erstellt"
|
||||||
|
|
||||||
#: lib/mv_web/live/custom_field_live/form.ex
|
#: lib/mv_web/live/custom_field_live/form_component.ex
|
||||||
#: lib/mv_web/live/custom_field_value_live/form.ex
|
#: lib/mv_web/live/custom_field_value_live/form.ex
|
||||||
#: lib/mv_web/live/member_live/form.ex
|
#: lib/mv_web/live/member_live/form.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
|
|
@ -237,8 +241,8 @@ msgstr "Ihre E-Mail-Adresse wurde bestätigt"
|
||||||
msgid "Your password has successfully been reset"
|
msgid "Your password has successfully been reset"
|
||||||
msgstr "Ihr Passwort wurde erfolgreich zurückgesetzt"
|
msgstr "Ihr Passwort wurde erfolgreich zurückgesetzt"
|
||||||
|
|
||||||
#: lib/mv_web/live/custom_field_live/form.ex
|
#: lib/mv_web/live/custom_field_live/form_component.ex
|
||||||
#: lib/mv_web/live/custom_field_live/index.ex
|
#: lib/mv_web/live/custom_field_live/index_component.ex
|
||||||
#: lib/mv_web/live/custom_field_value_live/form.ex
|
#: lib/mv_web/live/custom_field_value_live/form.ex
|
||||||
#: lib/mv_web/live/member_live/form.ex
|
#: lib/mv_web/live/member_live/form.ex
|
||||||
#: lib/mv_web/live/user_live/form.ex
|
#: lib/mv_web/live/user_live/form.ex
|
||||||
|
|
@ -251,7 +255,8 @@ msgstr "Abbrechen"
|
||||||
msgid "Choose a member"
|
msgid "Choose a member"
|
||||||
msgstr "Mitglied auswählen"
|
msgstr "Mitglied auswählen"
|
||||||
|
|
||||||
#: lib/mv_web/live/custom_field_live/form.ex
|
#: lib/mv_web/live/custom_field_live/form_component.ex
|
||||||
|
#: lib/mv_web/live/custom_field_live/index_component.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Description"
|
msgid "Description"
|
||||||
msgstr "Beschreibung"
|
msgstr "Beschreibung"
|
||||||
|
|
@ -266,7 +271,7 @@ msgstr "Benutzer*in bearbeiten"
|
||||||
msgid "Enabled"
|
msgid "Enabled"
|
||||||
msgstr "Aktiviert"
|
msgstr "Aktiviert"
|
||||||
|
|
||||||
#: lib/mv_web/live/custom_field_live/form.ex
|
#: lib/mv_web/live/custom_field_live/form_component.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Immutable"
|
msgid "Immutable"
|
||||||
msgstr "Unveränderlich"
|
msgstr "Unveränderlich"
|
||||||
|
|
@ -296,7 +301,8 @@ msgid "Members"
|
||||||
msgstr "Mitglieder"
|
msgstr "Mitglieder"
|
||||||
|
|
||||||
#: lib/mv_web/live/contribution_type_live/index.ex
|
#: lib/mv_web/live/contribution_type_live/index.ex
|
||||||
#: lib/mv_web/live/custom_field_live/form.ex
|
#: lib/mv_web/live/custom_field_live/form_component.ex
|
||||||
|
#: lib/mv_web/live/custom_field_live/index_component.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Name"
|
msgid "Name"
|
||||||
msgstr "Name"
|
msgstr "Name"
|
||||||
|
|
@ -327,7 +333,7 @@ msgstr "Passwort-Authentifizierung"
|
||||||
msgid "Profil"
|
msgid "Profil"
|
||||||
msgstr "Profil"
|
msgstr "Profil"
|
||||||
|
|
||||||
#: lib/mv_web/live/custom_field_live/form.ex
|
#: lib/mv_web/live/custom_field_live/form_component.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Required"
|
msgid "Required"
|
||||||
msgstr "Erforderlich"
|
msgstr "Erforderlich"
|
||||||
|
|
@ -343,6 +349,7 @@ msgid "Select member"
|
||||||
msgstr "Mitglied auswählen"
|
msgstr "Mitglied auswählen"
|
||||||
|
|
||||||
#: lib/mv_web/components/layouts/navbar.ex
|
#: lib/mv_web/components/layouts/navbar.ex
|
||||||
|
#: lib/mv_web/live/global_settings_live.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Settings"
|
msgid "Settings"
|
||||||
msgstr "Einstellungen"
|
msgstr "Einstellungen"
|
||||||
|
|
@ -383,7 +390,7 @@ msgstr "Benutzer*in"
|
||||||
msgid "Value"
|
msgid "Value"
|
||||||
msgstr "Wert"
|
msgstr "Wert"
|
||||||
|
|
||||||
#: lib/mv_web/live/custom_field_live/form.ex
|
#: lib/mv_web/live/custom_field_live/form_component.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Value type"
|
msgid "Value type"
|
||||||
msgstr "Wertetyp"
|
msgstr "Wertetyp"
|
||||||
|
|
@ -579,7 +586,7 @@ msgstr "Wähle ein Benutzerdefiniertes Feld"
|
||||||
msgid "Custom field"
|
msgid "Custom field"
|
||||||
msgstr "Benutzerdefinierte Felder"
|
msgstr "Benutzerdefinierte Felder"
|
||||||
|
|
||||||
#: lib/mv_web/live/custom_field_live/form.ex
|
#: lib/mv_web/live/global_settings_live.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Custom field %{action} successfully"
|
msgid "Custom field %{action} successfully"
|
||||||
msgstr "Benutzerdefiniertes Feld erfolgreich %{action}"
|
msgstr "Benutzerdefiniertes Feld erfolgreich %{action}"
|
||||||
|
|
@ -594,7 +601,7 @@ msgstr "Benutzerdefinierter Feldwert erfolgreich %{action}"
|
||||||
msgid "Please select a custom field first"
|
msgid "Please select a custom field first"
|
||||||
msgstr "Bitte wähle zuerst ein Benutzerdefiniertes Feld"
|
msgstr "Bitte wähle zuerst ein Benutzerdefiniertes Feld"
|
||||||
|
|
||||||
#: lib/mv_web/live/custom_field_live/form.ex
|
#: lib/mv_web/live/custom_field_live/form_component.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Save Custom field"
|
msgid "Save Custom field"
|
||||||
msgstr "Benutzerdefiniertes Feld speichern"
|
msgstr "Benutzerdefiniertes Feld speichern"
|
||||||
|
|
@ -604,12 +611,7 @@ msgstr "Benutzerdefiniertes Feld speichern"
|
||||||
msgid "Save Custom field value"
|
msgid "Save Custom field value"
|
||||||
msgstr "Benutzerdefinierten Feldwert speichern"
|
msgstr "Benutzerdefinierten Feldwert speichern"
|
||||||
|
|
||||||
#: lib/mv_web/live/custom_field_live/form.ex
|
#: lib/mv_web/live/custom_field_live/index_component.ex
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "Use this form to manage custom_field records in your database."
|
|
||||||
msgstr "Verwende dieses Formular, um Benutzerdefinierte Felder in deiner Datenbank zu verwalten."
|
|
||||||
|
|
||||||
#: lib/mv_web/components/layouts/navbar.ex
|
|
||||||
#: lib/mv_web/live/member_live/form.ex
|
#: lib/mv_web/live/member_live/form.ex
|
||||||
#: lib/mv_web/live/member_live/show.ex
|
#: lib/mv_web/live/member_live/show.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
|
|
@ -621,44 +623,39 @@ msgstr "Benutzerdefinierte Felder"
|
||||||
msgid "Use this form to manage Custom Field Value records in your database."
|
msgid "Use this form to manage Custom Field Value records in your database."
|
||||||
msgstr "Verwende dieses Formular, um Benutzerdefinierte Feldwerte in deiner Datenbank zu verwalten."
|
msgstr "Verwende dieses Formular, um Benutzerdefinierte Feldwerte in deiner Datenbank zu verwalten."
|
||||||
|
|
||||||
#: lib/mv_web/live/custom_field_live/show.ex
|
#: lib/mv_web/live/custom_field_live/index_component.ex
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "Auto-generated identifier (immutable)"
|
|
||||||
msgstr "Automatisch generierter Bezeichner (unveränderlich)"
|
|
||||||
|
|
||||||
#: lib/mv_web/live/custom_field_live/index.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "%{count} member has a value assigned for this custom field."
|
msgid "%{count} member has a value assigned for this custom field."
|
||||||
msgid_plural "%{count} members have values assigned for this custom field."
|
msgid_plural "%{count} members have values assigned for this custom field."
|
||||||
msgstr[0] "%{count} Mitglied hat einen Wert für dieses benutzerdefinierte Feld zugewiesen."
|
msgstr[0] "%{count} Mitglied hat einen Wert für dieses benutzerdefinierte Feld zugewiesen."
|
||||||
msgstr[1] "%{count} Mitglieder haben Werte für dieses benutzerdefinierte Feld zugewiesen."
|
msgstr[1] "%{count} Mitglieder haben Werte für dieses benutzerdefinierte Feld zugewiesen."
|
||||||
|
|
||||||
#: lib/mv_web/live/custom_field_live/index.ex
|
#: lib/mv_web/live/custom_field_live/index_component.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "All custom field values will be permanently deleted when you delete this custom field."
|
msgid "All custom field values will be permanently deleted when you delete this custom field."
|
||||||
msgstr "Alle benutzerdefinierten Feldwerte werden beim Löschen dieses benutzerdefinierten Feldes dauerhaft gelöscht."
|
msgstr "Alle benutzerdefinierten Feldwerte werden beim Löschen dieses benutzerdefinierten Feldes dauerhaft gelöscht."
|
||||||
|
|
||||||
#: lib/mv_web/live/custom_field_live/index.ex
|
#: lib/mv_web/live/custom_field_live/index_component.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Delete Custom Field"
|
msgid "Delete Custom Field"
|
||||||
msgstr "Benutzerdefiniertes Feld löschen"
|
msgstr "Benutzerdefiniertes Feld löschen"
|
||||||
|
|
||||||
#: lib/mv_web/live/custom_field_live/index.ex
|
#: lib/mv_web/live/custom_field_live/index_component.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Delete Custom Field and All Values"
|
msgid "Delete Custom Field and All Values"
|
||||||
msgstr "Benutzerdefiniertes Feld und alle Werte löschen"
|
msgstr "Benutzerdefiniertes Feld und alle Werte löschen"
|
||||||
|
|
||||||
#: lib/mv_web/live/custom_field_live/index.ex
|
#: lib/mv_web/live/custom_field_live/index_component.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Enter the text above to confirm"
|
msgid "Enter the text above to confirm"
|
||||||
msgstr "Obigen Text zur Bestätigung eingeben"
|
msgstr "Obigen Text zur Bestätigung eingeben"
|
||||||
|
|
||||||
#: lib/mv_web/live/custom_field_live/index.ex
|
#: lib/mv_web/live/custom_field_live/index_component.ex
|
||||||
#, elixir-autogen, elixir-format, fuzzy
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
msgid "To confirm deletion, please enter this text:"
|
msgid "To confirm deletion, please enter this text:"
|
||||||
msgstr "Um die Löschung zu bestätigen, gib bitte folgenden Text ein:"
|
msgstr "Um die Löschung zu bestätigen, gib bitte folgenden Text ein:"
|
||||||
|
|
||||||
#: lib/mv_web/live/custom_field_live/form.ex
|
#: lib/mv_web/live/custom_field_live/form_component.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Show in overview"
|
msgid "Show in overview"
|
||||||
msgstr "In der Mitglieder-Übersicht anzeigen"
|
msgstr "In der Mitglieder-Übersicht anzeigen"
|
||||||
|
|
@ -786,6 +783,7 @@ msgstr "Tipp: E-Mail-Adressen ins BCC-Feld einfügen für Datenschutzkonformitä
|
||||||
msgid "This field cannot be empty"
|
msgid "This field cannot be empty"
|
||||||
msgstr "Dieses Feld darf nicht leer bleiben"
|
msgstr "Dieses Feld darf nicht leer bleiben"
|
||||||
|
|
||||||
|
#: lib/mv_web/components/core_components.ex
|
||||||
#: lib/mv_web/live/components/payment_filter_component.ex
|
#: lib/mv_web/live/components/payment_filter_component.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "All"
|
msgid "All"
|
||||||
|
|
@ -1322,6 +1320,96 @@ msgstr ""
|
||||||
msgid "Yearly Interval - Joining Period Included"
|
msgid "Yearly Interval - Joining Period Included"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/components/field_visibility_dropdown_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Columns"
|
||||||
|
msgstr "Spalten"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/components/field_visibility_dropdown_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Custom Field %{id}"
|
||||||
|
msgstr "Benutzerdefiniertes Feld %{id}"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/index.html.heex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Last name"
|
||||||
|
msgstr "Nachname"
|
||||||
|
|
||||||
|
#: lib/mv_web/components/core_components.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "None"
|
||||||
|
msgstr "Keine"
|
||||||
|
|
||||||
|
#: lib/mv_web/components/core_components.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Options"
|
||||||
|
msgstr "Optionen"
|
||||||
|
|
||||||
|
#: lib/mv_web/components/core_components.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Select all"
|
||||||
|
msgstr "Alle auswählen"
|
||||||
|
|
||||||
|
#: lib/mv_web/components/core_components.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Select none"
|
||||||
|
msgstr "Keine auswählen"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/custom_field_live/form_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Back to custom field overview"
|
||||||
|
msgstr "Zurück zur Felderliste"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/global_settings_live.ex
|
||||||
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
|
msgid "Custom field deleted successfully"
|
||||||
|
msgstr "Benutzerdefiniertes Feld erfolgreich %{action}"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/custom_field_live/form_component.ex
|
||||||
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
|
msgid "Edit Custom Field"
|
||||||
|
msgstr "Benutzerdefiniertes Feld löschen"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/global_settings_live.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Failed to delete custom field: %{error}"
|
||||||
|
msgstr "Konnte Feld nicht löschen: %{error}"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/custom_field_live/form_component.ex
|
||||||
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
|
msgid "New Custom Field"
|
||||||
|
msgstr "Benutzerdefiniertes Feld speichern"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/custom_field_live/index_component.ex
|
||||||
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
|
msgid "New Custom field"
|
||||||
|
msgstr "Benutzerdefiniertes Feld speichern"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/custom_field_live/index_component.ex
|
||||||
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
|
msgid "Show in Overview"
|
||||||
|
msgstr "In der Mitglieder-Übersicht anzeigen"
|
||||||
|
|
||||||
|
#: lib/mv_web/live/global_settings_live.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Slug does not match. Deletion cancelled."
|
||||||
|
msgstr "Eingegebener Text war nicht korrekt. Vorgang abgebrochen."
|
||||||
|
|
||||||
|
#: lib/mv_web/live/custom_field_live/index_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "These will appear in addition to other data when adding new members."
|
||||||
|
msgstr "Diese Felder können zusätzlich zu den normalen Daten ausgefüllt werden, wenn ein neues Mitglied angelegt wird."
|
||||||
|
|
||||||
|
#: lib/mv_web/live/custom_field_live/index_component.ex
|
||||||
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
|
msgid "Value Type"
|
||||||
|
msgstr "Wertetyp"
|
||||||
|
|
||||||
|
#~ #: lib/mv_web/live/custom_field_live/show.ex
|
||||||
|
#~ #, elixir-autogen, elixir-format
|
||||||
|
#~ msgid "Auto-generated identifier (immutable)"
|
||||||
|
#~ msgstr "Automatisch generierter Bezeichner (unveränderlich)"
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/member_live/form.ex
|
#~ #: lib/mv_web/live/member_live/form.ex
|
||||||
#~ #: lib/mv_web/live/member_live/show.ex
|
#~ #: lib/mv_web/live/member_live/show.ex
|
||||||
#~ #, elixir-autogen, elixir-format
|
#~ #, elixir-autogen, elixir-format
|
||||||
|
|
@ -1366,3 +1454,8 @@ msgstr ""
|
||||||
#~ #, elixir-autogen, elixir-format
|
#~ #, elixir-autogen, elixir-format
|
||||||
#~ msgid "This is a member record from your database."
|
#~ msgid "This is a member record from your database."
|
||||||
#~ msgstr "Dies ist ein Mitglied aus deiner Datenbank."
|
#~ msgstr "Dies ist ein Mitglied aus deiner Datenbank."
|
||||||
|
|
||||||
|
#~ #: lib/mv_web/live/custom_field_live/form.ex
|
||||||
|
#~ #, elixir-autogen, elixir-format
|
||||||
|
#~ msgid "Use this form to manage custom_field records in your database."
|
||||||
|
#~ msgstr "Verwende dieses Formular, um Benutzerdefinierte Felder in deiner Datenbank zu verwalten."
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@ msgid "City"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/contribution_type_live/index.ex
|
#: lib/mv_web/live/contribution_type_live/index.ex
|
||||||
|
#: lib/mv_web/live/custom_field_live/index_component.ex
|
||||||
#: lib/mv_web/live/member_live/index.html.heex
|
#: lib/mv_web/live/member_live/index.html.heex
|
||||||
#: lib/mv_web/live/user_live/index.html.heex
|
#: lib/mv_web/live/user_live/index.html.heex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
|
|
@ -42,6 +43,7 @@ msgid "Delete"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/contribution_type_live/index.ex
|
#: lib/mv_web/live/contribution_type_live/index.ex
|
||||||
|
#: lib/mv_web/live/custom_field_live/index_component.ex
|
||||||
#: lib/mv_web/live/member_live/index.html.heex
|
#: lib/mv_web/live/member_live/index.html.heex
|
||||||
#: lib/mv_web/live/user_live/form.ex
|
#: lib/mv_web/live/user_live/form.ex
|
||||||
#: lib/mv_web/live/user_live/index.html.heex
|
#: lib/mv_web/live/user_live/index.html.heex
|
||||||
|
|
@ -155,7 +157,7 @@ msgstr ""
|
||||||
msgid "Save Member"
|
msgid "Save Member"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/custom_field_live/form.ex
|
#: lib/mv_web/live/custom_field_live/form_component.ex
|
||||||
#: lib/mv_web/live/custom_field_value_live/form.ex
|
#: lib/mv_web/live/custom_field_value_live/form.ex
|
||||||
#: lib/mv_web/live/global_settings_live.ex
|
#: lib/mv_web/live/global_settings_live.ex
|
||||||
#: lib/mv_web/live/member_live/form.ex
|
#: lib/mv_web/live/member_live/form.ex
|
||||||
|
|
@ -170,6 +172,7 @@ msgstr ""
|
||||||
msgid "Street"
|
msgid "Street"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/custom_field_live/index_component.ex
|
||||||
#: lib/mv_web/live/member_live/index.html.heex
|
#: lib/mv_web/live/member_live/index.html.heex
|
||||||
#: lib/mv_web/live/member_live/index/formatter.ex
|
#: lib/mv_web/live/member_live/index/formatter.ex
|
||||||
#: lib/mv_web/live/member_live/show.ex
|
#: lib/mv_web/live/member_live/show.ex
|
||||||
|
|
@ -182,6 +185,7 @@ msgstr ""
|
||||||
msgid "Show Member"
|
msgid "Show Member"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/custom_field_live/index_component.ex
|
||||||
#: lib/mv_web/live/member_live/index.html.heex
|
#: lib/mv_web/live/member_live/index.html.heex
|
||||||
#: lib/mv_web/live/member_live/index/formatter.ex
|
#: lib/mv_web/live/member_live/index/formatter.ex
|
||||||
#: lib/mv_web/live/member_live/show.ex
|
#: lib/mv_web/live/member_live/show.ex
|
||||||
|
|
@ -189,14 +193,14 @@ msgstr ""
|
||||||
msgid "Yes"
|
msgid "Yes"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/custom_field_live/form.ex
|
#: lib/mv_web/live/custom_field_live/form_component.ex
|
||||||
#: lib/mv_web/live/custom_field_value_live/form.ex
|
#: lib/mv_web/live/custom_field_value_live/form.ex
|
||||||
#: lib/mv_web/live/member_live/form.ex
|
#: lib/mv_web/live/member_live/form.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "create"
|
msgid "create"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/custom_field_live/form.ex
|
#: lib/mv_web/live/custom_field_live/form_component.ex
|
||||||
#: lib/mv_web/live/custom_field_value_live/form.ex
|
#: lib/mv_web/live/custom_field_value_live/form.ex
|
||||||
#: lib/mv_web/live/member_live/form.ex
|
#: lib/mv_web/live/member_live/form.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
|
|
@ -238,8 +242,8 @@ msgstr ""
|
||||||
msgid "Your password has successfully been reset"
|
msgid "Your password has successfully been reset"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/custom_field_live/form.ex
|
#: lib/mv_web/live/custom_field_live/form_component.ex
|
||||||
#: lib/mv_web/live/custom_field_live/index.ex
|
#: lib/mv_web/live/custom_field_live/index_component.ex
|
||||||
#: lib/mv_web/live/custom_field_value_live/form.ex
|
#: lib/mv_web/live/custom_field_value_live/form.ex
|
||||||
#: lib/mv_web/live/member_live/form.ex
|
#: lib/mv_web/live/member_live/form.ex
|
||||||
#: lib/mv_web/live/user_live/form.ex
|
#: lib/mv_web/live/user_live/form.ex
|
||||||
|
|
@ -252,7 +256,8 @@ msgstr ""
|
||||||
msgid "Choose a member"
|
msgid "Choose a member"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/custom_field_live/form.ex
|
#: lib/mv_web/live/custom_field_live/form_component.ex
|
||||||
|
#: lib/mv_web/live/custom_field_live/index_component.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Description"
|
msgid "Description"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
@ -267,7 +272,7 @@ msgstr ""
|
||||||
msgid "Enabled"
|
msgid "Enabled"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/custom_field_live/form.ex
|
#: lib/mv_web/live/custom_field_live/form_component.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Immutable"
|
msgid "Immutable"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
@ -297,7 +302,8 @@ msgid "Members"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/contribution_type_live/index.ex
|
#: lib/mv_web/live/contribution_type_live/index.ex
|
||||||
#: lib/mv_web/live/custom_field_live/form.ex
|
#: lib/mv_web/live/custom_field_live/form_component.ex
|
||||||
|
#: lib/mv_web/live/custom_field_live/index_component.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Name"
|
msgid "Name"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
@ -328,7 +334,7 @@ msgstr ""
|
||||||
msgid "Profil"
|
msgid "Profil"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/custom_field_live/form.ex
|
#: lib/mv_web/live/custom_field_live/form_component.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Required"
|
msgid "Required"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
@ -344,6 +350,7 @@ msgid "Select member"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/components/layouts/navbar.ex
|
#: lib/mv_web/components/layouts/navbar.ex
|
||||||
|
#: lib/mv_web/live/global_settings_live.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Settings"
|
msgid "Settings"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
@ -384,7 +391,7 @@ msgstr ""
|
||||||
msgid "Value"
|
msgid "Value"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/custom_field_live/form.ex
|
#: lib/mv_web/live/custom_field_live/form_component.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Value type"
|
msgid "Value type"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
@ -580,7 +587,7 @@ msgstr ""
|
||||||
msgid "Custom field"
|
msgid "Custom field"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/custom_field_live/form.ex
|
#: lib/mv_web/live/global_settings_live.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Custom field %{action} successfully"
|
msgid "Custom field %{action} successfully"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
@ -595,7 +602,7 @@ msgstr ""
|
||||||
msgid "Please select a custom field first"
|
msgid "Please select a custom field first"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/custom_field_live/form.ex
|
#: lib/mv_web/live/custom_field_live/form_component.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Save Custom field"
|
msgid "Save Custom field"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
@ -605,12 +612,7 @@ msgstr ""
|
||||||
msgid "Save Custom field value"
|
msgid "Save Custom field value"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/custom_field_live/form.ex
|
#: lib/mv_web/live/custom_field_live/index_component.ex
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "Use this form to manage custom_field records in your database."
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: lib/mv_web/components/layouts/navbar.ex
|
|
||||||
#: lib/mv_web/live/member_live/form.ex
|
#: lib/mv_web/live/member_live/form.ex
|
||||||
#: lib/mv_web/live/member_live/show.ex
|
#: lib/mv_web/live/member_live/show.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
|
|
@ -622,44 +624,39 @@ msgstr ""
|
||||||
msgid "Use this form to manage Custom Field Value records in your database."
|
msgid "Use this form to manage Custom Field Value records in your database."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/custom_field_live/show.ex
|
#: lib/mv_web/live/custom_field_live/index_component.ex
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "Auto-generated identifier (immutable)"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: lib/mv_web/live/custom_field_live/index.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "%{count} member has a value assigned for this custom field."
|
msgid "%{count} member has a value assigned for this custom field."
|
||||||
msgid_plural "%{count} members have values assigned for this custom field."
|
msgid_plural "%{count} members have values assigned for this custom field."
|
||||||
msgstr[0] ""
|
msgstr[0] ""
|
||||||
msgstr[1] ""
|
msgstr[1] ""
|
||||||
|
|
||||||
#: lib/mv_web/live/custom_field_live/index.ex
|
#: lib/mv_web/live/custom_field_live/index_component.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "All custom field values will be permanently deleted when you delete this custom field."
|
msgid "All custom field values will be permanently deleted when you delete this custom field."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/custom_field_live/index.ex
|
#: lib/mv_web/live/custom_field_live/index_component.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Delete Custom Field"
|
msgid "Delete Custom Field"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/custom_field_live/index.ex
|
#: lib/mv_web/live/custom_field_live/index_component.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Delete Custom Field and All Values"
|
msgid "Delete Custom Field and All Values"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/custom_field_live/index.ex
|
#: lib/mv_web/live/custom_field_live/index_component.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Enter the text above to confirm"
|
msgid "Enter the text above to confirm"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/custom_field_live/index.ex
|
#: lib/mv_web/live/custom_field_live/index_component.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "To confirm deletion, please enter this text:"
|
msgid "To confirm deletion, please enter this text:"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/custom_field_live/form.ex
|
#: lib/mv_web/live/custom_field_live/form_component.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Show in overview"
|
msgid "Show in overview"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
@ -787,6 +784,7 @@ msgstr ""
|
||||||
msgid "This field cannot be empty"
|
msgid "This field cannot be empty"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/components/core_components.ex
|
||||||
#: lib/mv_web/live/components/payment_filter_component.ex
|
#: lib/mv_web/live/components/payment_filter_component.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "All"
|
msgid "All"
|
||||||
|
|
@ -1322,3 +1320,88 @@ msgstr ""
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Yearly Interval - Joining Period Included"
|
msgid "Yearly Interval - Joining Period Included"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/components/field_visibility_dropdown_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Columns"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/components/field_visibility_dropdown_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Custom Field %{id}"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/index.html.heex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Last name"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/components/core_components.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "None"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/components/core_components.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Options"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/components/core_components.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Select all"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/components/core_components.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Select none"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/custom_field_live/form_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Back to custom field overview"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/global_settings_live.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Custom field deleted successfully"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/custom_field_live/form_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Edit Custom Field"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/global_settings_live.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Failed to delete custom field: %{error}"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/custom_field_live/form_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "New Custom Field"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/custom_field_live/index_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "New Custom field"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/custom_field_live/index_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Show in Overview"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/global_settings_live.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Slug does not match. Deletion cancelled."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/custom_field_live/index_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "These will appear in addition to other data when adding new members."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/custom_field_live/index_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Value Type"
|
||||||
|
msgstr ""
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@ msgid "City"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/contribution_type_live/index.ex
|
#: lib/mv_web/live/contribution_type_live/index.ex
|
||||||
|
#: lib/mv_web/live/custom_field_live/index_component.ex
|
||||||
#: lib/mv_web/live/member_live/index.html.heex
|
#: lib/mv_web/live/member_live/index.html.heex
|
||||||
#: lib/mv_web/live/user_live/index.html.heex
|
#: lib/mv_web/live/user_live/index.html.heex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
|
|
@ -42,6 +43,7 @@ msgid "Delete"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/contribution_type_live/index.ex
|
#: lib/mv_web/live/contribution_type_live/index.ex
|
||||||
|
#: lib/mv_web/live/custom_field_live/index_component.ex
|
||||||
#: lib/mv_web/live/member_live/index.html.heex
|
#: lib/mv_web/live/member_live/index.html.heex
|
||||||
#: lib/mv_web/live/user_live/form.ex
|
#: lib/mv_web/live/user_live/form.ex
|
||||||
#: lib/mv_web/live/user_live/index.html.heex
|
#: lib/mv_web/live/user_live/index.html.heex
|
||||||
|
|
@ -155,7 +157,7 @@ msgstr ""
|
||||||
msgid "Save Member"
|
msgid "Save Member"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/custom_field_live/form.ex
|
#: lib/mv_web/live/custom_field_live/form_component.ex
|
||||||
#: lib/mv_web/live/custom_field_value_live/form.ex
|
#: lib/mv_web/live/custom_field_value_live/form.ex
|
||||||
#: lib/mv_web/live/global_settings_live.ex
|
#: lib/mv_web/live/global_settings_live.ex
|
||||||
#: lib/mv_web/live/member_live/form.ex
|
#: lib/mv_web/live/member_live/form.ex
|
||||||
|
|
@ -170,6 +172,7 @@ msgstr ""
|
||||||
msgid "Street"
|
msgid "Street"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/custom_field_live/index_component.ex
|
||||||
#: lib/mv_web/live/member_live/index.html.heex
|
#: lib/mv_web/live/member_live/index.html.heex
|
||||||
#: lib/mv_web/live/member_live/index/formatter.ex
|
#: lib/mv_web/live/member_live/index/formatter.ex
|
||||||
#: lib/mv_web/live/member_live/show.ex
|
#: lib/mv_web/live/member_live/show.ex
|
||||||
|
|
@ -182,6 +185,7 @@ msgstr ""
|
||||||
msgid "Show Member"
|
msgid "Show Member"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/custom_field_live/index_component.ex
|
||||||
#: lib/mv_web/live/member_live/index.html.heex
|
#: lib/mv_web/live/member_live/index.html.heex
|
||||||
#: lib/mv_web/live/member_live/index/formatter.ex
|
#: lib/mv_web/live/member_live/index/formatter.ex
|
||||||
#: lib/mv_web/live/member_live/show.ex
|
#: lib/mv_web/live/member_live/show.ex
|
||||||
|
|
@ -189,14 +193,14 @@ msgstr ""
|
||||||
msgid "Yes"
|
msgid "Yes"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/custom_field_live/form.ex
|
#: lib/mv_web/live/custom_field_live/form_component.ex
|
||||||
#: lib/mv_web/live/custom_field_value_live/form.ex
|
#: lib/mv_web/live/custom_field_value_live/form.ex
|
||||||
#: lib/mv_web/live/member_live/form.ex
|
#: lib/mv_web/live/member_live/form.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "create"
|
msgid "create"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/custom_field_live/form.ex
|
#: lib/mv_web/live/custom_field_live/form_component.ex
|
||||||
#: lib/mv_web/live/custom_field_value_live/form.ex
|
#: lib/mv_web/live/custom_field_value_live/form.ex
|
||||||
#: lib/mv_web/live/member_live/form.ex
|
#: lib/mv_web/live/member_live/form.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
|
|
@ -238,8 +242,8 @@ msgstr ""
|
||||||
msgid "Your password has successfully been reset"
|
msgid "Your password has successfully been reset"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/custom_field_live/form.ex
|
#: lib/mv_web/live/custom_field_live/form_component.ex
|
||||||
#: lib/mv_web/live/custom_field_live/index.ex
|
#: lib/mv_web/live/custom_field_live/index_component.ex
|
||||||
#: lib/mv_web/live/custom_field_value_live/form.ex
|
#: lib/mv_web/live/custom_field_value_live/form.ex
|
||||||
#: lib/mv_web/live/member_live/form.ex
|
#: lib/mv_web/live/member_live/form.ex
|
||||||
#: lib/mv_web/live/user_live/form.ex
|
#: lib/mv_web/live/user_live/form.ex
|
||||||
|
|
@ -252,7 +256,8 @@ msgstr ""
|
||||||
msgid "Choose a member"
|
msgid "Choose a member"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/custom_field_live/form.ex
|
#: lib/mv_web/live/custom_field_live/form_component.ex
|
||||||
|
#: lib/mv_web/live/custom_field_live/index_component.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Description"
|
msgid "Description"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
@ -267,7 +272,7 @@ msgstr ""
|
||||||
msgid "Enabled"
|
msgid "Enabled"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/custom_field_live/form.ex
|
#: lib/mv_web/live/custom_field_live/form_component.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Immutable"
|
msgid "Immutable"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
@ -297,7 +302,8 @@ msgid "Members"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/contribution_type_live/index.ex
|
#: lib/mv_web/live/contribution_type_live/index.ex
|
||||||
#: lib/mv_web/live/custom_field_live/form.ex
|
#: lib/mv_web/live/custom_field_live/form_component.ex
|
||||||
|
#: lib/mv_web/live/custom_field_live/index_component.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Name"
|
msgid "Name"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
@ -328,7 +334,7 @@ msgstr ""
|
||||||
msgid "Profil"
|
msgid "Profil"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/custom_field_live/form.ex
|
#: lib/mv_web/live/custom_field_live/form_component.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Required"
|
msgid "Required"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
@ -344,6 +350,7 @@ msgid "Select member"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/components/layouts/navbar.ex
|
#: lib/mv_web/components/layouts/navbar.ex
|
||||||
|
#: lib/mv_web/live/global_settings_live.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Settings"
|
msgid "Settings"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
@ -384,7 +391,7 @@ msgstr ""
|
||||||
msgid "Value"
|
msgid "Value"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/custom_field_live/form.ex
|
#: lib/mv_web/live/custom_field_live/form_component.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Value type"
|
msgid "Value type"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
@ -580,7 +587,7 @@ msgstr ""
|
||||||
msgid "Custom field"
|
msgid "Custom field"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/custom_field_live/form.ex
|
#: lib/mv_web/live/global_settings_live.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Custom field %{action} successfully"
|
msgid "Custom field %{action} successfully"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
@ -595,7 +602,7 @@ msgstr ""
|
||||||
msgid "Please select a custom field first"
|
msgid "Please select a custom field first"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/custom_field_live/form.ex
|
#: lib/mv_web/live/custom_field_live/form_component.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Save Custom field"
|
msgid "Save Custom field"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
@ -605,12 +612,7 @@ msgstr ""
|
||||||
msgid "Save Custom field value"
|
msgid "Save Custom field value"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/custom_field_live/form.ex
|
#: lib/mv_web/live/custom_field_live/index_component.ex
|
||||||
#, elixir-autogen, elixir-format, fuzzy
|
|
||||||
msgid "Use this form to manage custom_field records in your database."
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: lib/mv_web/components/layouts/navbar.ex
|
|
||||||
#: lib/mv_web/live/member_live/form.ex
|
#: lib/mv_web/live/member_live/form.ex
|
||||||
#: lib/mv_web/live/member_live/show.ex
|
#: lib/mv_web/live/member_live/show.ex
|
||||||
#, elixir-autogen, elixir-format, fuzzy
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
|
|
@ -622,44 +624,39 @@ msgstr ""
|
||||||
msgid "Use this form to manage Custom Field Value records in your database."
|
msgid "Use this form to manage Custom Field Value records in your database."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/custom_field_live/show.ex
|
#: lib/mv_web/live/custom_field_live/index_component.ex
|
||||||
#, elixir-autogen, elixir-format
|
|
||||||
msgid "Auto-generated identifier (immutable)"
|
|
||||||
msgstr ""
|
|
||||||
|
|
||||||
#: lib/mv_web/live/custom_field_live/index.ex
|
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "%{count} member has a value assigned for this custom field."
|
msgid "%{count} member has a value assigned for this custom field."
|
||||||
msgid_plural "%{count} members have values assigned for this custom field."
|
msgid_plural "%{count} members have values assigned for this custom field."
|
||||||
msgstr[0] ""
|
msgstr[0] ""
|
||||||
msgstr[1] ""
|
msgstr[1] ""
|
||||||
|
|
||||||
#: lib/mv_web/live/custom_field_live/index.ex
|
#: lib/mv_web/live/custom_field_live/index_component.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "All custom field values will be permanently deleted when you delete this custom field."
|
msgid "All custom field values will be permanently deleted when you delete this custom field."
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/custom_field_live/index.ex
|
#: lib/mv_web/live/custom_field_live/index_component.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Delete Custom Field"
|
msgid "Delete Custom Field"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/custom_field_live/index.ex
|
#: lib/mv_web/live/custom_field_live/index_component.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Delete Custom Field and All Values"
|
msgid "Delete Custom Field and All Values"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/custom_field_live/index.ex
|
#: lib/mv_web/live/custom_field_live/index_component.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Enter the text above to confirm"
|
msgid "Enter the text above to confirm"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/custom_field_live/index.ex
|
#: lib/mv_web/live/custom_field_live/index_component.ex
|
||||||
#, elixir-autogen, elixir-format, fuzzy
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
msgid "To confirm deletion, please enter this text:"
|
msgid "To confirm deletion, please enter this text:"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
#: lib/mv_web/live/custom_field_live/form.ex
|
#: lib/mv_web/live/custom_field_live/form_component.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "Show in overview"
|
msgid "Show in overview"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
@ -787,6 +784,7 @@ msgstr ""
|
||||||
msgid "This field cannot be empty"
|
msgid "This field cannot be empty"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/components/core_components.ex
|
||||||
#: lib/mv_web/live/components/payment_filter_component.ex
|
#: lib/mv_web/live/components/payment_filter_component.ex
|
||||||
#, elixir-autogen, elixir-format
|
#, elixir-autogen, elixir-format
|
||||||
msgid "All"
|
msgid "All"
|
||||||
|
|
@ -1323,6 +1321,96 @@ msgstr ""
|
||||||
msgid "Yearly Interval - Joining Period Included"
|
msgid "Yearly Interval - Joining Period Included"
|
||||||
msgstr ""
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/components/field_visibility_dropdown_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Columns"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/components/field_visibility_dropdown_component.ex
|
||||||
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
|
msgid "Custom Field %{id}"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/member_live/index.html.heex
|
||||||
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
|
msgid "Last name"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/components/core_components.ex
|
||||||
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
|
msgid "None"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/components/core_components.ex
|
||||||
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
|
msgid "Options"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/components/core_components.ex
|
||||||
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
|
msgid "Select all"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/components/core_components.ex
|
||||||
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
|
msgid "Select none"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/custom_field_live/form_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Back to custom field overview"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/global_settings_live.ex
|
||||||
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
|
msgid "Custom field deleted successfully"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/custom_field_live/form_component.ex
|
||||||
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
|
msgid "Edit Custom Field"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/global_settings_live.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Failed to delete custom field: %{error}"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/custom_field_live/form_component.ex
|
||||||
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
|
msgid "New Custom Field"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/custom_field_live/index_component.ex
|
||||||
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
|
msgid "New Custom field"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/custom_field_live/index_component.ex
|
||||||
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
|
msgid "Show in Overview"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/global_settings_live.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "Slug does not match. Deletion cancelled."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/custom_field_live/index_component.ex
|
||||||
|
#, elixir-autogen, elixir-format
|
||||||
|
msgid "These will appear in addition to other data when adding new members."
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#: lib/mv_web/live/custom_field_live/index_component.ex
|
||||||
|
#, elixir-autogen, elixir-format, fuzzy
|
||||||
|
msgid "Value Type"
|
||||||
|
msgstr ""
|
||||||
|
|
||||||
|
#~ #: lib/mv_web/live/custom_field_live/show.ex
|
||||||
|
#~ #, elixir-autogen, elixir-format
|
||||||
|
#~ msgid "Auto-generated identifier (immutable)"
|
||||||
|
#~ msgstr ""
|
||||||
|
|
||||||
#~ #: lib/mv_web/live/member_live/form.ex
|
#~ #: lib/mv_web/live/member_live/form.ex
|
||||||
#~ #: lib/mv_web/live/member_live/show.ex
|
#~ #: lib/mv_web/live/member_live/show.ex
|
||||||
#~ #, elixir-autogen, elixir-format
|
#~ #, elixir-autogen, elixir-format
|
||||||
|
|
@ -1365,3 +1453,8 @@ msgstr ""
|
||||||
#~ #, elixir-autogen, elixir-format
|
#~ #, elixir-autogen, elixir-format
|
||||||
#~ msgid "This is a member record from your database."
|
#~ msgid "This is a member record from your database."
|
||||||
#~ msgstr ""
|
#~ msgstr ""
|
||||||
|
|
||||||
|
#~ #: lib/mv_web/live/custom_field_live/form.ex
|
||||||
|
#~ #, elixir-autogen, elixir-format, fuzzy
|
||||||
|
#~ msgid "Use this form to manage custom_field records in your database."
|
||||||
|
#~ msgstr ""
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -148,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"
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
Loading…
Add table
Add a link
Reference in a new issue