Merge pull request 'Show custom fields per default in member overview closes #197 and #153' (#208) from feature/197_custom_fields_overview into main
All checks were successful
continuous-integration/drone/push Build is passing

Reviewed-on: #208
Reviewed-by: moritz <moritz@noreply.git.local-it.org>
This commit is contained in:
carla 2025-12-01 10:05:29 +01:00
commit a132383d81
18 changed files with 1899 additions and 186 deletions

View file

@ -329,6 +329,11 @@ end
---
**PR #208:** *Show custom fields per default in member overview* 🔧
- added show_in_overview as attribute to custom fields
- show custom fields in member overview per default
- can be set to false in the settings for the specific custom field
## Implementation Decisions
### Architecture Patterns
@ -390,6 +395,7 @@ defmodule Mv.Membership.CustomField do
attribute :value_type, :atom # :string, :integer, :boolean, :date, :email
attribute :immutable, :boolean # Can't change after creation
attribute :required, :boolean # All members must have this
attribute :show_in_overview, :boolean # "If true, this custom field will be displayed in the member overview table"
end
# CustomFieldValue stores values

View file

@ -94,15 +94,18 @@
- ✅ CustomFieldValue type management
- ✅ Dynamic custom field value assignment to members
- ✅ Union type storage (JSONB)
- ✅ Default field visibility configuration
**Closed Issues:**
- [#194](https://git.local-it.org/local-it/mitgliederverwaltung/issues/194) - Custom Fields: Harden implementation (S)
- [#197](https://git.local-it.org/local-it/mitgliederverwaltung/issues/197) - Custom Fields: Add option to show custom fields in member overview (M)
**Open Issues:**
- [#194](https://git.local-it.org/local-it/mitgliederverwaltung/issues/194) - Custom Fields: Harden implementation (S) [0/3 tasks]
- [#157](https://git.local-it.org/local-it/mitgliederverwaltung/issues/157) - Concept how custom fields are handled (M, High priority) [0/4 tasks]
- [#161](https://git.local-it.org/local-it/mitgliederverwaltung/issues/161) - Don't show birthday field for default configurations (S, Low priority)
- [#153](https://git.local-it.org/local-it/mitgliederverwaltung/issues/153) - Sorting functionalities for custom fields (M, Low priority)
**Missing Features:**
- ❌ Default field visibility configuration
- ❌ Field groups/categories
- ❌ Conditional fields (show field X if field Y = value)
- ❌ Field validation rules (min/max, regex patterns)

View file

@ -14,6 +14,7 @@ defmodule Mv.Membership.CustomField do
- `description` - Optional human-readable description
- `immutable` - If true, custom field values cannot be changed after creation
- `required` - If true, all members must have this custom field (future feature)
- `show_in_overview` - If true, this custom field will be displayed in the member overview table and can be sorted
## Supported Value Types
- `:string` - Text data (max 10,000 characters)
@ -59,10 +60,10 @@ defmodule Mv.Membership.CustomField do
actions do
defaults [:read, :update]
default_accept [:name, :value_type, :description, :immutable, :required]
default_accept [:name, :value_type, :description, :immutable, :required, :show_in_overview]
create :create do
accept [:name, :value_type, :description, :immutable, :required]
accept [:name, :value_type, :description, :immutable, :required, :show_in_overview]
change Mv.Membership.CustomField.Changes.GenerateSlug
validate string_length(:slug, min: 1)
end
@ -119,6 +120,12 @@ defmodule Mv.Membership.CustomField do
attribute :required, :boolean,
default: false,
allow_nil?: false
attribute :show_in_overview, :boolean,
default: true,
allow_nil?: false,
public?: true,
description: "If true, this custom field will be displayed in the member overview table"
end
relationships do

View file

@ -318,6 +318,13 @@ defmodule MvWeb.CoreComponents do
default: &Function.identity/1,
doc: "the function for mapping each row before calling the :col and :action slots"
attr :dynamic_cols, :list,
default: [],
doc: "list of dynamic column definitions with :custom_field and :render functions"
attr :sort_field, :any, default: nil, doc: "current sort field"
attr :sort_order, :atom, default: nil, doc: "current sort order"
slot :col, required: true do
attr :label, :string
end
@ -335,6 +342,16 @@ defmodule MvWeb.CoreComponents do
<thead>
<tr>
<th :for={col <- @col}>{col[:label]}</th>
<th :for={dyn_col <- @dynamic_cols}>
<.live_component
module={MvWeb.Components.SortHeaderComponent}
id={:"sort_custom_field_#{dyn_col[:custom_field].id}"}
field={"custom_field_#{dyn_col[:custom_field].id}"}
label={dyn_col[:custom_field].name}
sort_field={@sort_field}
sort_order={@sort_order}
/>
</th>
<th :if={@action != []}>
<span class="sr-only">{gettext("Actions")}</span>
</th>
@ -349,6 +366,23 @@ defmodule MvWeb.CoreComponents do
>
{render_slot(col, @row_item.(row))}
</td>
<td
:for={dyn_col <- @dynamic_cols}
phx-click={@row_click && @row_click.(row)}
class={@row_click && "hover:cursor-pointer"}
>
{if dyn_col[:render] do
rendered = dyn_col[:render].(@row_item.(row))
if rendered == "" do
""
else
rendered
end
else
""
end}
</td>
<td :if={@action != []} class="w-0 font-semibold">
<div class="flex gap-4">
<%= for action <- @action do %>

View file

@ -18,6 +18,7 @@ defmodule MvWeb.CustomFieldLive.Form do
- 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)
@ -60,6 +61,7 @@ defmodule MvWeb.CustomFieldLive.Form do
<.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")}

View file

@ -26,6 +26,14 @@ defmodule MvWeb.MemberLive.Index do
"""
use MvWeb, :live_view
require Ash.Query
import Ash.Expr
alias MvWeb.MemberLive.Index.Formatter
# Prefix used in sort field names for custom fields (e.g., "custom_field_<id>")
@custom_field_prefix "custom_field_"
@doc """
Initializes the LiveView state.
@ -34,6 +42,16 @@ defmodule MvWeb.MemberLive.Index do
"""
@impl true
def mount(_params, _session, socket) do
# Load custom fields that should be shown in overview
# 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
# should be visible to the user rather than silently failing.
custom_fields_visible =
Mv.Membership.CustomField
|> Ash.Query.filter(expr(show_in_overview == true))
|> Ash.Query.sort(name: :asc)
|> Ash.read!()
socket =
socket
|> assign(:page_title, gettext("Members"))
@ -41,6 +59,7 @@ defmodule MvWeb.MemberLive.Index do
|> assign_new(:sort_field, fn -> :first_name end)
|> assign_new(:sort_order, fn -> :asc end)
|> assign(:selected_members, [])
|> assign(:custom_fields_visible, custom_fields_visible)
# We call handle params to use the query from the URL
{:ok, socket}
@ -60,6 +79,8 @@ defmodule MvWeb.MemberLive.Index do
"""
@impl true
def handle_event("delete", %{"id" => id}, socket) do
# Note: Using bang versions (!) - errors will be handled by Phoenix LiveView
# This ensures users see error messages if deletion fails (e.g., permission denied)
member = Ash.get!(Mv.Membership.Member, id)
Ash.destroy!(member)
@ -108,7 +129,14 @@ defmodule MvWeb.MemberLive.Index do
"""
@impl true
def handle_info({:sort, field_str}, socket) do
field = String.to_existing_atom(field_str)
# Handle both atom and string field names (for custom fields)
field =
try do
String.to_existing_atom(field_str)
rescue
ArgumentError -> field_str
end
{new_field, new_order} = determine_new_sort(field, socket)
socket
@ -158,10 +186,38 @@ defmodule MvWeb.MemberLive.Index do
|> maybe_update_search(params)
|> maybe_update_sort(params)
|> load_members(params["query"])
|> prepare_dynamic_cols()
{:noreply, socket}
end
# Prepares dynamic column definitions for custom fields that should be shown in the overview.
#
# Creates a list of column definitions, each containing:
# - `:custom_field` - The CustomField resource
# - `:render` - A function that formats the custom field value for a given member
#
# Returns the socket with `:dynamic_cols` assigned.
defp prepare_dynamic_cols(socket) do
dynamic_cols =
Enum.map(socket.assigns.custom_fields_visible, fn custom_field ->
%{
custom_field: custom_field,
render: fn member ->
case get_custom_field_value(member, custom_field) do
nil ->
""
cfv ->
Formatter.format_custom_field_value(cfv.value, custom_field)
end
end
}
end)
assign(socket, :dynamic_cols, dynamic_cols)
end
# -------------------------------------------------------------
# FUNCTIONS
# -------------------------------------------------------------
@ -177,8 +233,8 @@ defmodule MvWeb.MemberLive.Index do
# Updates both the active and old SortHeader components
defp update_sort_components(socket, old_field, new_field, new_order) do
active_id = :"sort_#{new_field}"
old_id = :"sort_#{old_field}"
active_id = to_sort_id(new_field)
old_id = to_sort_id(old_field)
# Update the new SortHeader
send_update(MvWeb.Components.SortHeaderComponent,
@ -197,11 +253,32 @@ defmodule MvWeb.MemberLive.Index do
socket
end
# Converts a field (atom or string) to a sort component ID atom
# Handles both existing atoms and strings that need to be converted
defp to_sort_id(field) when is_binary(field) do
try do
String.to_existing_atom("sort_#{field}")
rescue
ArgumentError -> :"sort_#{field}"
end
end
defp to_sort_id(field) when is_atom(field) do
:"sort_#{field}"
end
# Builds sort URL and pushes navigation patch
defp push_sort_url(socket, field, order) do
field_str =
if is_atom(field) do
Atom.to_string(field)
else
field
end
query_params = %{
"query" => socket.assigns.query,
"sort_field" => Atom.to_string(field),
"sort_field" => field_str,
"sort_order" => Atom.to_string(order)
}
@ -214,7 +291,24 @@ defmodule MvWeb.MemberLive.Index do
)}
end
# Load members eg based on a query for sorting
# Loads members from the database with custom field values and applies search/sort filters.
#
# Process:
# 1. Builds base query with selected fields
# 2. Loads custom field values for visible custom fields (filtered at database level)
# 3. Applies search filter if provided
# 4. Applies sorting (database-level for regular fields, in-memory for custom fields)
#
# Performance Considerations:
# - Database-level filtering: Custom field values are filtered directly in the database
# using Ash relationship filters, reducing memory usage and improving performance.
# - In-memory sorting: Custom field sorting is done in memory after loading.
# This is suitable for small to medium datasets (<1000 members).
# For larger datasets, consider implementing database-level sorting or pagination.
# - No pagination: All matching members are loaded at once. For large result sets,
# consider implementing pagination (see Issue #165).
#
# Returns the socket with `:members` assigned.
defp load_members(socket, search_query) do
query =
Mv.Membership.Member
@ -232,16 +326,71 @@ defmodule MvWeb.MemberLive.Index do
:join_date
])
# Load custom field values for visible custom fields
custom_field_ids_list = Enum.map(socket.assigns.custom_fields_visible, & &1.id)
query = load_custom_field_values(query, custom_field_ids_list)
# Apply the search filter first
query = apply_search_filter(query, search_query)
# Apply sorting based on current socket state
query = maybe_sort(query, socket.assigns.sort_field, socket.assigns.sort_order)
# For custom fields, we sort after loading
{query, sort_after_load} =
maybe_sort(
query,
socket.assigns.sort_field,
socket.assigns.sort_order,
socket.assigns.custom_fields_visible
)
# Note: Using Ash.read! - errors will be handled by Phoenix LiveView
# This is appropriate for data loading in LiveViews
members = Ash.read!(query)
# Custom field values are already filtered at the database level in load_custom_field_values/2
# No need for in-memory filtering anymore
# Sort in memory if needed (for custom fields)
members =
if sort_after_load do
sort_members_in_memory(
members,
socket.assigns.sort_field,
socket.assigns.sort_order,
socket.assigns.custom_fields_visible
)
else
members
end
assign(socket, :members, members)
end
# Load custom field values for the given custom field IDs
#
# Filters custom field values directly in the database using Ash relationship filters.
# This is more efficient than loading all values and filtering in memory.
#
# Performance: Database-level filtering reduces:
# - Memory usage (only visible custom field values are loaded)
# - Network transfer (less data from database to application)
# - Processing time (no need to iterate through all members and filter)
defp load_custom_field_values(query, []) do
query
end
defp load_custom_field_values(query, custom_field_ids) when length(custom_field_ids) > 0 do
# Filter custom field values at the database level using Ash relationship query
# This ensures only visible custom field values are loaded
custom_field_values_query =
Mv.Membership.CustomFieldValue
|> Ash.Query.filter(expr(custom_field_id in ^custom_field_ids))
|> Ash.Query.load(custom_field: [:id, :name, :value_type])
query
|> Ash.Query.load(custom_field_values: custom_field_values_query)
end
# -------------------------------------------------------------
# Helper Functions
# -------------------------------------------------------------
@ -264,15 +413,24 @@ defmodule MvWeb.MemberLive.Index do
defp toggle_order(nil), do: :asc
# Function to sort the column if needed
defp maybe_sort(query, nil, _), do: query
# Returns {query, sort_after_load} where sort_after_load is true if we need to sort in memory
defp maybe_sort(query, nil, _, _), do: {query, false}
defp maybe_sort(query, field, :asc) when not is_nil(field),
do: Ash.Query.sort(query, [{field, :asc}])
defp maybe_sort(query, field, order, _custom_fields) when not is_nil(field) do
if custom_field_sort?(field) do
# Custom fields need to be sorted in memory after loading
{query, true}
else
# Only sort by atom fields (regular member fields) in database
if is_atom(field) do
{Ash.Query.sort(query, [{field, order}]), false}
else
{query, false}
end
end
end
defp maybe_sort(query, field, :desc) when not is_nil(field),
do: Ash.Query.sort(query, [{field, :desc}])
defp maybe_sort(query, _, _), do: query
defp maybe_sort(query, _, _, _), do: {query, false}
# Validate that a field is sortable
defp valid_sort_field?(field) when is_atom(field) do
@ -288,12 +446,188 @@ defmodule MvWeb.MemberLive.Index do
:join_date
]
field in valid_fields
field in valid_fields or custom_field_sort?(field)
end
defp valid_sort_field?(field) when is_binary(field) do
custom_field_sort?(field)
end
defp valid_sort_field?(_), do: false
# Function to maybe update the sort
# Check if field is a custom field sort field (format: custom_field_<id>)
defp custom_field_sort?(field) when is_atom(field) do
field_str = Atom.to_string(field)
String.starts_with?(field_str, @custom_field_prefix)
end
defp custom_field_sort?(field) when is_binary(field) do
String.starts_with?(field, @custom_field_prefix)
end
defp custom_field_sort?(_), do: false
# Extracts the custom field ID from a sort field name.
#
# Sort fields for custom fields use the format: "custom_field_<id>"
# This function extracts the ID part.
#
# Examples:
# extract_custom_field_id("custom_field_123") -> "123"
# extract_custom_field_id(:custom_field_123) -> "123"
# extract_custom_field_id("first_name") -> nil
defp extract_custom_field_id(field) when is_atom(field) do
field_str = Atom.to_string(field)
extract_custom_field_id(field_str)
end
defp extract_custom_field_id(field) when is_binary(field) do
case String.split(field, @custom_field_prefix) do
["", id_str] -> id_str
_ -> nil
end
end
defp extract_custom_field_id(_), do: nil
# Sorts members in memory by a custom field value.
#
# Process:
# 1. Extracts custom field ID from sort field name
# 2. Finds the corresponding CustomField resource
# 3. Splits members into those with values and those without
# 4. Sorts members with values by the extracted value
# 5. Combines: sorted values first, then NULL/empty values at the end
#
# Performance Note:
# This function sorts in memory, which is suitable for small to medium datasets (<1000 members).
# For larger datasets, consider implementing database-level sorting or pagination.
#
# Parameters:
# - `members` - List of Member resources to sort
# - `field` - Sort field name (format: "custom_field_<id>" or atom)
# - `order` - Sort order (`:asc` or `:desc`)
# - `custom_fields` - List of visible CustomField resources
#
# Returns the sorted list of members.
defp sort_members_in_memory(members, field, order, custom_fields) do
custom_field_id_str = extract_custom_field_id(field)
case custom_field_id_str do
nil ->
members
id_str ->
sort_members_by_custom_field(members, id_str, order, custom_fields)
end
end
# Sorts members by a specific custom field ID
defp sort_members_by_custom_field(members, id_str, order, custom_fields) do
custom_field = find_custom_field_by_id(custom_fields, id_str)
case custom_field do
nil ->
members
cf ->
sort_members_with_custom_field(members, cf, order)
end
end
# Finds a custom field by matching its ID string
defp find_custom_field_by_id(custom_fields, id_str) do
Enum.find(custom_fields, fn cf ->
to_string(cf.id) == id_str
end)
end
# Sorts members that have a specific custom field
defp sort_members_with_custom_field(members, custom_field, order) do
# Split members into those with values and those without (NULL/empty)
{members_with_values, members_without_values} =
split_members_by_value_presence(members, custom_field)
# Sort members with values
sorted_with_values = sort_members_with_values(members_with_values, custom_field, order)
# Combine: sorted values first, then NULL/empty values at the end
sorted_with_values ++ members_without_values
end
# Splits members into those with values and those without
defp split_members_by_value_presence(members, custom_field) do
Enum.split_with(members, fn member ->
has_non_empty_value?(member, custom_field)
end)
end
# Checks if a member has a non-empty value for the custom field
defp has_non_empty_value?(member, custom_field) do
case get_custom_field_value(member, custom_field) do
nil ->
false
cfv ->
extracted = extract_sort_value(cfv.value, custom_field.value_type)
not empty_value?(extracted, custom_field.value_type)
end
end
# Sorts members that have values for the custom field
defp sort_members_with_values(members_with_values, custom_field, order) do
sorted =
Enum.sort_by(members_with_values, fn member ->
cfv = get_custom_field_value(member, custom_field)
extracted = extract_sort_value(cfv.value, custom_field.value_type)
normalize_sort_value(extracted, order)
end)
# For DESC, reverse only the members with values
if order == :desc do
Enum.reverse(sorted)
else
sorted
end
end
# Extracts a sortable value from a custom field value based on its type.
#
# Handles different value formats:
# - `%Ash.Union{}` - Extracts value and type from union
# - Direct values - Returns as-is for primitive types
#
# Returns the extracted value suitable for sorting.
defp extract_sort_value(%Ash.Union{value: value, type: type}, _expected_type) do
extract_sort_value(value, type)
end
defp extract_sort_value(value, :string) when is_binary(value), do: value
defp extract_sort_value(value, :integer) when is_integer(value), do: value
defp extract_sort_value(value, :boolean) when is_boolean(value), do: value
defp extract_sort_value(%Date{} = date, :date), do: date
defp extract_sort_value(value, :email) when is_binary(value), do: value
defp extract_sort_value(value, _type), do: to_string(value)
# Check if a value is considered empty (NULL or empty string)
defp empty_value?(value, :string) when is_binary(value) do
String.trim(value) == ""
end
defp empty_value?(value, :email) when is_binary(value) do
String.trim(value) == ""
end
defp empty_value?(_value, _type), do: false
# Normalize sort value for DESC order
# For DESC, we sort ascending first, then reverse the list
# This function is kept for consistency but doesn't need to invert values
defp normalize_sort_value(value, _order), do: value
# Updates sort field and order from URL parameters if present.
#
# Validates the sort field and order, falling back to defaults if invalid.
defp maybe_update_sort(socket, %{"sort_field" => sf, "sort_order" => so}) do
field = determine_field(socket.assigns.sort_field, sf)
order = determine_order(socket.assigns.sort_order, so)
@ -305,33 +639,50 @@ defmodule MvWeb.MemberLive.Index do
defp maybe_update_sort(socket, _), do: socket
defp determine_field(default, sf) do
case sf do
"" ->
default
# Determine sort field from URL parameter, validating against allowed fields
defp determine_field(default, ""), do: default
defp determine_field(default, nil), do: default
nil ->
default
sf when is_binary(sf) ->
sf
|> String.to_existing_atom()
|> handle_atom_conversion(default)
sf when is_atom(sf) ->
handle_atom_conversion(sf, default)
_ ->
default
# Determines the valid sort field from a URL parameter.
#
# Validates the field against allowed sort fields (regular member fields or custom fields).
# Falls back to default if the field is invalid.
#
# Parameters:
# - `default` - Default field to use if validation fails
# - `sf` - Sort field from URL (can be atom, string, nil, or empty string)
#
# Returns a valid sort field (atom or string for custom fields).
defp determine_field(default, sf) when is_binary(sf) do
# Check if it's a custom field sort (starts with "custom_field_")
if custom_field_sort?(sf) do
if valid_sort_field?(sf), do: sf, else: default
else
# Try to convert to atom for regular fields
try do
atom = String.to_existing_atom(sf)
if valid_sort_field?(atom), do: atom, else: default
rescue
ArgumentError -> default
end
end
end
defp handle_atom_conversion(val, default) when is_atom(val) do
if valid_sort_field?(val), do: val, else: default
defp determine_field(default, sf) when is_atom(sf) do
if valid_sort_field?(sf), do: sf, else: default
end
defp handle_atom_conversion(_, default), do: default
defp determine_field(default, _), do: default
# Determines the valid sort order from a URL parameter.
#
# Validates that the order is either "asc" or "desc", falling back to default if invalid.
#
# Parameters:
# - `default` - Default order to use if validation fails
# - `so` - Sort order from URL (string, atom, nil, or empty string)
#
# Returns `:asc` or `:desc`.
defp determine_order(default, so) do
case so do
"" -> default
@ -350,4 +701,36 @@ defmodule MvWeb.MemberLive.Index do
# Keep the previous search query if no new one is provided
socket
end
# -------------------------------------------------------------
# Helper Functions for Custom Field Values
# -------------------------------------------------------------
# Retrieves the custom field value for a specific member and custom field.
#
# Searches through the member's `custom_field_values` relationship to find
# the value matching the given custom field.
#
# Returns:
# - `%CustomFieldValue{}` if found
# - `nil` if not found or if member has no custom field values
#
# Examples:
# get_custom_field_value(member, custom_field) -> %CustomFieldValue{...}
# get_custom_field_value(member, non_existent_field) -> nil
def get_custom_field_value(member, custom_field) do
case member.custom_field_values do
nil ->
nil
values when is_list(values) ->
Enum.find(values, fn cfv ->
cfv.custom_field_id == custom_field.id or
(cfv.custom_field && cfv.custom_field.id == custom_field.id)
end)
_ ->
nil
end
end
end

View file

@ -19,6 +19,9 @@
id="members"
rows={@members}
row_click={fn member -> JS.navigate(~p"/members/#{member}") end}
dynamic_cols={@dynamic_cols}
sort_field={@sort_field}
sort_order={@sort_order}
>
<!-- <:col :let={member} label="Id">{member.id}</:col> -->
@ -185,7 +188,6 @@
>
{member.join_date}
</:col>
<:action :let={member}>
<div class="sr-only">
<.link navigate={~p"/members/#{member}"}>{gettext("Show")}</.link>

View file

@ -0,0 +1,74 @@
defmodule MvWeb.MemberLive.Index.Formatter do
@moduledoc """
Formats custom field values for display in the member overview table.
Handles different value types (string, integer, boolean, date, email) and
formats them appropriately for display in the UI.
"""
use Gettext, backend: MvWeb.Gettext
@doc """
Formats a custom field value for display.
Handles different input formats:
- `nil` - Returns empty string
- `%Ash.Union{}` - Extracts value and type from union type
- Map (JSONB format) - Extracts type and value from map keys
- Direct value - Uses custom_field.value_type to determine format
## Examples
iex> format_custom_field_value(nil, %CustomField{value_type: :string})
""
iex> format_custom_field_value("test", %CustomField{value_type: :string})
"test"
iex> format_custom_field_value(true, %CustomField{value_type: :boolean})
"Yes"
"""
def format_custom_field_value(nil, _custom_field), do: ""
def format_custom_field_value(%Ash.Union{value: value, type: type}, custom_field) do
format_value_by_type(value, type, custom_field)
end
def format_custom_field_value(value, custom_field) when is_map(value) do
# Handle map format from JSONB
type = Map.get(value, "type") || Map.get(value, "_union_type")
val = Map.get(value, "value") || Map.get(value, "_union_value")
format_value_by_type(val, type, custom_field)
end
def format_custom_field_value(value, custom_field) do
format_value_by_type(value, custom_field.value_type, custom_field)
end
# Format value based on type
defp format_value_by_type(value, :string, _), do: to_string(value)
defp format_value_by_type(value, :integer, _), do: to_string(value)
defp format_value_by_type(value, type, _) when type in [:string, :email] and is_binary(value) do
# Return empty string if value is empty
if String.trim(value) == "", do: "", else: value
end
defp format_value_by_type(value, :email, _), do: to_string(value)
defp format_value_by_type(value, :boolean, _) when value == true, do: gettext("Yes")
defp format_value_by_type(value, :boolean, _) when value == false, do: gettext("No")
defp format_value_by_type(value, :boolean, _), do: to_string(value)
defp format_value_by_type(%Date{} = date, :date, _), do: Date.to_string(date)
defp format_value_by_type(value, :date, _) when is_binary(value) do
case Date.from_iso8601(value) do
{:ok, date} -> Date.to_string(date)
_ -> value
end
end
defp format_value_by_type(value, _type, _), do: to_string(value)
end

View file

@ -10,13 +10,13 @@ msgid ""
msgstr ""
"Language: en\n"
#: lib/mv_web/components/core_components.ex:339
#: lib/mv_web/components/core_components.ex:356
#, elixir-autogen, elixir-format
msgid "Actions"
msgstr "Aktionen"
#: lib/mv_web/live/member_live/index.html.heex:200
#: lib/mv_web/live/user_live/index.html.heex:72
#: lib/mv_web/live/member_live/index.html.heex:202
#: lib/mv_web/live/user_live/index.html.heex:65
#, elixir-autogen, elixir-format
msgid "Are you sure?"
msgstr "Bist du sicher?"
@ -28,21 +28,21 @@ msgid "Attempting to reconnect"
msgstr "Verbindung wird wiederhergestellt"
#: lib/mv_web/live/member_live/form.ex:54
#: lib/mv_web/live/member_live/index.html.heex:145
#: lib/mv_web/live/member_live/index.html.heex:148
#: lib/mv_web/live/member_live/show.ex:59
#, elixir-autogen, elixir-format
msgid "City"
msgstr "Stadt"
#: lib/mv_web/live/member_live/index.html.heex:202
#: lib/mv_web/live/user_live/index.html.heex:74
#: lib/mv_web/live/member_live/index.html.heex:204
#: lib/mv_web/live/user_live/index.html.heex:67
#, elixir-autogen, elixir-format
msgid "Delete"
msgstr "Löschen"
#: lib/mv_web/live/member_live/index.html.heex:194
#: lib/mv_web/live/user_live/form.ex:251
#: lib/mv_web/live/user_live/index.html.heex:66
#: lib/mv_web/live/member_live/index.html.heex:196
#: lib/mv_web/live/user_live/form.ex:141
#: lib/mv_web/live/user_live/index.html.heex:59
#, elixir-autogen, elixir-format
msgid "Edit"
msgstr "Bearbeite"
@ -54,7 +54,7 @@ msgid "Edit Member"
msgstr "Mitglied bearbeiten"
#: lib/mv_web/live/member_live/form.ex:47
#: lib/mv_web/live/member_live/index.html.heex:77
#: lib/mv_web/live/member_live/index.html.heex:80
#: lib/mv_web/live/member_live/show.ex:50
#: lib/mv_web/live/user_live/form.ex:46
#: lib/mv_web/live/user_live/index.html.heex:44
@ -70,7 +70,7 @@ msgid "First Name"
msgstr "Vorname"
#: lib/mv_web/live/member_live/form.ex:51
#: lib/mv_web/live/member_live/index.html.heex:179
#: lib/mv_web/live/member_live/index.html.heex:182
#: lib/mv_web/live/member_live/show.ex:56
#, elixir-autogen, elixir-format
msgid "Join Date"
@ -87,8 +87,8 @@ msgstr "Nachname"
msgid "New Member"
msgstr "Neues Mitglied"
#: lib/mv_web/live/member_live/index.html.heex:191
#: lib/mv_web/live/user_live/index.html.heex:63
#: lib/mv_web/live/member_live/index.html.heex:193
#: lib/mv_web/live/user_live/index.html.heex:56
#, elixir-autogen, elixir-format
msgid "Show"
msgstr "Anzeigen"
@ -121,7 +121,7 @@ msgid "Exit Date"
msgstr "Austrittsdatum"
#: lib/mv_web/live/member_live/form.ex:56
#: lib/mv_web/live/member_live/index.html.heex:111
#: lib/mv_web/live/member_live/index.html.heex:114
#: lib/mv_web/live/member_live/show.ex:61
#, elixir-autogen, elixir-format
msgid "House Number"
@ -140,14 +140,14 @@ msgid "Paid"
msgstr "Bezahlt"
#: lib/mv_web/live/member_live/form.ex:50
#: lib/mv_web/live/member_live/index.html.heex:162
#: lib/mv_web/live/member_live/index.html.heex:165
#: lib/mv_web/live/member_live/show.ex:55
#, elixir-autogen, elixir-format
msgid "Phone Number"
msgstr "Telefonnummer"
#: lib/mv_web/live/member_live/form.ex:57
#: lib/mv_web/live/member_live/index.html.heex:128
#: lib/mv_web/live/member_live/index.html.heex:131
#: lib/mv_web/live/member_live/show.ex:62
#, elixir-autogen, elixir-format
msgid "Postal Code"
@ -158,7 +158,7 @@ msgstr "Postleitzahl"
msgid "Save Member"
msgstr "Mitglied speichern"
#: lib/mv_web/live/custom_field_live/form.ex:64
#: lib/mv_web/live/custom_field_live/form.ex:66
#: lib/mv_web/live/custom_field_value_live/form.ex:74
#: lib/mv_web/live/member_live/form.ex:79
#: lib/mv_web/live/user_live/form.ex:234
@ -167,7 +167,7 @@ msgid "Saving..."
msgstr "Speichern..."
#: lib/mv_web/live/member_live/form.ex:55
#: lib/mv_web/live/member_live/index.html.heex:94
#: lib/mv_web/live/member_live/index.html.heex:97
#: lib/mv_web/live/member_live/show.ex:60
#, elixir-autogen, elixir-format
msgid "Street"
@ -183,6 +183,7 @@ msgstr "Dieses Formular dient zur Verwaltung von Mitgliedern und deren Eigenscha
msgid "Id"
msgstr "ID"
#: lib/mv_web/live/member_live/index/formatter.ex:65
#: lib/mv_web/live/member_live/show.ex:53
#, elixir-autogen, elixir-format
msgid "No"
@ -198,19 +199,20 @@ msgstr "Mitglied anzeigen"
msgid "This is a member record from your database."
msgstr "Dies ist ein Mitglied aus deiner Datenbank."
#: lib/mv_web/live/member_live/index/formatter.ex:64
#: lib/mv_web/live/member_live/show.ex:53
#, elixir-autogen, elixir-format
msgid "Yes"
msgstr "Ja"
#: lib/mv_web/live/custom_field_live/form.ex:108
#: lib/mv_web/live/custom_field_live/form.ex:110
#: lib/mv_web/live/custom_field_value_live/form.ex:233
#: lib/mv_web/live/member_live/form.ex:138
#, elixir-autogen, elixir-format
msgid "create"
msgstr "erstellt"
#: lib/mv_web/live/custom_field_live/form.ex:109
#: lib/mv_web/live/custom_field_live/form.ex:111
#: lib/mv_web/live/custom_field_value_live/form.ex:234
#: lib/mv_web/live/member_live/form.ex:139
#, elixir-autogen, elixir-format
@ -252,7 +254,7 @@ msgstr "Ihre E-Mail-Adresse wurde bestätigt"
msgid "Your password has successfully been reset"
msgstr "Ihr Passwort wurde erfolgreich zurückgesetzt"
#: lib/mv_web/live/custom_field_live/form.ex:67
#: lib/mv_web/live/custom_field_live/form.ex:69
#: lib/mv_web/live/custom_field_live/index.ex:120
#: lib/mv_web/live/custom_field_value_live/form.ex:77
#: lib/mv_web/live/member_live/form.ex:82
@ -266,7 +268,7 @@ msgstr "Abbrechen"
msgid "Choose a member"
msgstr "Mitglied auswählen"
#: lib/mv_web/live/custom_field_live/form.ex:60
#: lib/mv_web/live/custom_field_live/form.ex:61
#, elixir-autogen, elixir-format
msgid "Description"
msgstr "Beschreibung"
@ -286,7 +288,7 @@ msgstr "Aktiviert"
msgid "ID"
msgstr "ID"
#: lib/mv_web/live/custom_field_live/form.ex:61
#: lib/mv_web/live/custom_field_live/form.ex:62
#, elixir-autogen, elixir-format
msgid "Immutable"
msgstr "Unveränderlich"
@ -308,13 +310,13 @@ msgid "Member"
msgstr "Mitglied"
#: lib/mv_web/components/layouts/navbar.ex:19
#: lib/mv_web/live/member_live/index.ex:39
#: lib/mv_web/live/member_live/index.ex:57
#: lib/mv_web/live/member_live/index.html.heex:3
#, elixir-autogen, elixir-format
msgid "Members"
msgstr "Mitglieder"
#: lib/mv_web/live/custom_field_live/form.ex:50
#: lib/mv_web/live/custom_field_live/form.ex:51
#, elixir-autogen, elixir-format
msgid "Name"
msgstr "Name"
@ -357,17 +359,17 @@ msgstr "Passwort-Authentifizierung"
msgid "Profil"
msgstr "Profil"
#: lib/mv_web/live/custom_field_live/form.ex:62
#: lib/mv_web/live/custom_field_live/form.ex:63
#, elixir-autogen, elixir-format
msgid "Required"
msgstr "Erforderlich"
#: lib/mv_web/live/member_live/index.html.heex:34
#: lib/mv_web/live/member_live/index.html.heex:37
#, elixir-autogen, elixir-format
msgid "Select all members"
msgstr "Alle Mitglieder auswählen"
#: lib/mv_web/live/member_live/index.html.heex:48
#: lib/mv_web/live/member_live/index.html.heex:51
#, elixir-autogen, elixir-format
msgid "Select member"
msgstr "Mitglied auswählen"
@ -413,7 +415,7 @@ msgstr "Benutzer*in"
msgid "Value"
msgstr "Wert"
#: lib/mv_web/live/custom_field_live/form.ex:55
#: lib/mv_web/live/custom_field_live/form.ex:56
#, elixir-autogen, elixir-format
msgid "Value type"
msgstr "Wertetyp"
@ -569,7 +571,7 @@ msgstr "Benutzer*innen"
msgid "Click to sort"
msgstr "Klicke um zu sortieren"
#: lib/mv_web/live/member_live/index.html.heex:60
#: lib/mv_web/live/member_live/index.html.heex:63
#, elixir-autogen, elixir-format
msgid "First name"
msgstr "Vorname"
@ -621,7 +623,7 @@ msgstr "Benutzerdefinierte Feldwerte"
msgid "Custom field"
msgstr "Benutzerdefiniertes Feld"
#: lib/mv_web/live/custom_field_live/form.ex:115
#: lib/mv_web/live/custom_field_live/form.ex:117
#, elixir-autogen, elixir-format
msgid "Custom field %{action} successfully"
msgstr "Benutzerdefiniertes Feld erfolgreich %{action}"
@ -636,7 +638,7 @@ msgstr "Benutzerdefinierter Feldwert erfolgreich %{action}"
msgid "Please select a custom field first"
msgstr "Bitte wähle zuerst ein Benutzerdefiniertes Feld"
#: lib/mv_web/live/custom_field_live/form.ex:65
#: lib/mv_web/live/custom_field_live/form.ex:67
#, elixir-autogen, elixir-format
msgid "Save Custom field"
msgstr "Benutzerdefiniertes Feld speichern"
@ -646,7 +648,7 @@ msgstr "Benutzerdefiniertes Feld speichern"
msgid "Save Custom field value"
msgstr "Benutzerdefinierten Feldwert speichern"
#: lib/mv_web/live/custom_field_live/form.ex:45
#: lib/mv_web/live/custom_field_live/form.ex:46
#, 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."
@ -747,3 +749,12 @@ msgstr "Entverknüpfung geplant"
#, elixir-autogen, elixir-format
msgid "Failed to link member: %{error}"
msgstr ""
#: lib/mv_web/live/custom_field_live/form.ex:64
#, elixir-autogen, elixir-format
msgid "Show in overview"
msgstr "In der Mitglieder-Übersicht anzeigen"
#~ #: lib/mv_web/live/custom_field_live/index.ex:97
#~ #, elixir-autogen, elixir-format
#~ msgid "To confirm deletion, please enter the custom field slug:"
#~ msgstr "Um die Löschung zu bestätigen, gib bitte den Slug des benutzerdefinierten Feldes ein:"

View file

@ -11,13 +11,13 @@
msgid ""
msgstr ""
#: lib/mv_web/components/core_components.ex:339
#: lib/mv_web/components/core_components.ex:356
#, elixir-autogen, elixir-format
msgid "Actions"
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex:200
#: lib/mv_web/live/user_live/index.html.heex:72
#: lib/mv_web/live/member_live/index.html.heex:202
#: lib/mv_web/live/user_live/index.html.heex:65
#, elixir-autogen, elixir-format
msgid "Are you sure?"
msgstr ""
@ -29,21 +29,21 @@ msgid "Attempting to reconnect"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:54
#: lib/mv_web/live/member_live/index.html.heex:145
#: lib/mv_web/live/member_live/index.html.heex:148
#: lib/mv_web/live/member_live/show.ex:59
#, elixir-autogen, elixir-format
msgid "City"
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex:202
#: lib/mv_web/live/user_live/index.html.heex:74
#: lib/mv_web/live/member_live/index.html.heex:204
#: lib/mv_web/live/user_live/index.html.heex:67
#, elixir-autogen, elixir-format
msgid "Delete"
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex:194
#: lib/mv_web/live/user_live/form.ex:251
#: lib/mv_web/live/user_live/index.html.heex:66
#: lib/mv_web/live/member_live/index.html.heex:196
#: lib/mv_web/live/user_live/form.ex:141
#: lib/mv_web/live/user_live/index.html.heex:59
#, elixir-autogen, elixir-format
msgid "Edit"
msgstr ""
@ -55,7 +55,7 @@ msgid "Edit Member"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:47
#: lib/mv_web/live/member_live/index.html.heex:77
#: lib/mv_web/live/member_live/index.html.heex:80
#: lib/mv_web/live/member_live/show.ex:50
#: lib/mv_web/live/user_live/form.ex:46
#: lib/mv_web/live/user_live/index.html.heex:44
@ -71,7 +71,7 @@ msgid "First Name"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:51
#: lib/mv_web/live/member_live/index.html.heex:179
#: lib/mv_web/live/member_live/index.html.heex:182
#: lib/mv_web/live/member_live/show.ex:56
#, elixir-autogen, elixir-format
msgid "Join Date"
@ -88,8 +88,8 @@ msgstr ""
msgid "New Member"
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex:191
#: lib/mv_web/live/user_live/index.html.heex:63
#: lib/mv_web/live/member_live/index.html.heex:193
#: lib/mv_web/live/user_live/index.html.heex:56
#, elixir-autogen, elixir-format
msgid "Show"
msgstr ""
@ -122,7 +122,7 @@ msgid "Exit Date"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:56
#: lib/mv_web/live/member_live/index.html.heex:111
#: lib/mv_web/live/member_live/index.html.heex:114
#: lib/mv_web/live/member_live/show.ex:61
#, elixir-autogen, elixir-format
msgid "House Number"
@ -141,14 +141,14 @@ msgid "Paid"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:50
#: lib/mv_web/live/member_live/index.html.heex:162
#: lib/mv_web/live/member_live/index.html.heex:165
#: lib/mv_web/live/member_live/show.ex:55
#, elixir-autogen, elixir-format
msgid "Phone Number"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:57
#: lib/mv_web/live/member_live/index.html.heex:128
#: lib/mv_web/live/member_live/index.html.heex:131
#: lib/mv_web/live/member_live/show.ex:62
#, elixir-autogen, elixir-format
msgid "Postal Code"
@ -159,7 +159,7 @@ msgstr ""
msgid "Save Member"
msgstr ""
#: lib/mv_web/live/custom_field_live/form.ex:64
#: lib/mv_web/live/custom_field_live/form.ex:66
#: lib/mv_web/live/custom_field_value_live/form.ex:74
#: lib/mv_web/live/member_live/form.ex:79
#: lib/mv_web/live/user_live/form.ex:234
@ -168,7 +168,7 @@ msgid "Saving..."
msgstr ""
#: lib/mv_web/live/member_live/form.ex:55
#: lib/mv_web/live/member_live/index.html.heex:94
#: lib/mv_web/live/member_live/index.html.heex:97
#: lib/mv_web/live/member_live/show.ex:60
#, elixir-autogen, elixir-format
msgid "Street"
@ -184,6 +184,7 @@ msgstr ""
msgid "Id"
msgstr ""
#: lib/mv_web/live/member_live/index/formatter.ex:65
#: lib/mv_web/live/member_live/show.ex:53
#, elixir-autogen, elixir-format
msgid "No"
@ -199,19 +200,20 @@ msgstr ""
msgid "This is a member record from your database."
msgstr ""
#: lib/mv_web/live/member_live/index/formatter.ex:64
#: lib/mv_web/live/member_live/show.ex:53
#, elixir-autogen, elixir-format
msgid "Yes"
msgstr ""
#: lib/mv_web/live/custom_field_live/form.ex:108
#: lib/mv_web/live/custom_field_live/form.ex:110
#: lib/mv_web/live/custom_field_value_live/form.ex:233
#: lib/mv_web/live/member_live/form.ex:138
#, elixir-autogen, elixir-format
msgid "create"
msgstr ""
#: lib/mv_web/live/custom_field_live/form.ex:109
#: lib/mv_web/live/custom_field_live/form.ex:111
#: lib/mv_web/live/custom_field_value_live/form.ex:234
#: lib/mv_web/live/member_live/form.ex:139
#, elixir-autogen, elixir-format
@ -253,7 +255,7 @@ msgstr ""
msgid "Your password has successfully been reset"
msgstr ""
#: lib/mv_web/live/custom_field_live/form.ex:67
#: lib/mv_web/live/custom_field_live/form.ex:69
#: lib/mv_web/live/custom_field_live/index.ex:120
#: lib/mv_web/live/custom_field_value_live/form.ex:77
#: lib/mv_web/live/member_live/form.ex:82
@ -267,7 +269,7 @@ msgstr ""
msgid "Choose a member"
msgstr ""
#: lib/mv_web/live/custom_field_live/form.ex:60
#: lib/mv_web/live/custom_field_live/form.ex:61
#, elixir-autogen, elixir-format
msgid "Description"
msgstr ""
@ -287,7 +289,7 @@ msgstr ""
msgid "ID"
msgstr ""
#: lib/mv_web/live/custom_field_live/form.ex:61
#: lib/mv_web/live/custom_field_live/form.ex:62
#, elixir-autogen, elixir-format
msgid "Immutable"
msgstr ""
@ -309,13 +311,13 @@ msgid "Member"
msgstr ""
#: lib/mv_web/components/layouts/navbar.ex:19
#: lib/mv_web/live/member_live/index.ex:39
#: lib/mv_web/live/member_live/index.ex:57
#: lib/mv_web/live/member_live/index.html.heex:3
#, elixir-autogen, elixir-format
msgid "Members"
msgstr ""
#: lib/mv_web/live/custom_field_live/form.ex:50
#: lib/mv_web/live/custom_field_live/form.ex:51
#, elixir-autogen, elixir-format
msgid "Name"
msgstr ""
@ -358,17 +360,17 @@ msgstr ""
msgid "Profil"
msgstr ""
#: lib/mv_web/live/custom_field_live/form.ex:62
#: lib/mv_web/live/custom_field_live/form.ex:63
#, elixir-autogen, elixir-format
msgid "Required"
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex:34
#: lib/mv_web/live/member_live/index.html.heex:37
#, elixir-autogen, elixir-format
msgid "Select all members"
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex:48
#: lib/mv_web/live/member_live/index.html.heex:51
#, elixir-autogen, elixir-format
msgid "Select member"
msgstr ""
@ -414,7 +416,7 @@ msgstr ""
msgid "Value"
msgstr ""
#: lib/mv_web/live/custom_field_live/form.ex:55
#: lib/mv_web/live/custom_field_live/form.ex:56
#, elixir-autogen, elixir-format
msgid "Value type"
msgstr ""
@ -570,7 +572,7 @@ msgstr ""
msgid "Click to sort"
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex:60
#: lib/mv_web/live/member_live/index.html.heex:63
#, elixir-autogen, elixir-format
msgid "First name"
msgstr ""
@ -622,7 +624,7 @@ msgstr ""
msgid "Custom field"
msgstr ""
#: lib/mv_web/live/custom_field_live/form.ex:115
#: lib/mv_web/live/custom_field_live/form.ex:117
#, elixir-autogen, elixir-format
msgid "Custom field %{action} successfully"
msgstr ""
@ -637,7 +639,7 @@ msgstr ""
msgid "Please select a custom field first"
msgstr ""
#: lib/mv_web/live/custom_field_live/form.ex:65
#: lib/mv_web/live/custom_field_live/form.ex:67
#, elixir-autogen, elixir-format
msgid "Save Custom field"
msgstr ""
@ -647,7 +649,7 @@ msgstr ""
msgid "Save Custom field value"
msgstr ""
#: lib/mv_web/live/custom_field_live/form.ex:45
#: lib/mv_web/live/custom_field_live/form.ex:46
#, elixir-autogen, elixir-format
msgid "Use this form to manage custom_field records in your database."
msgstr ""
@ -699,52 +701,7 @@ msgstr ""
msgid "To confirm deletion, please enter this text:"
msgstr ""
#: lib/mv_web/live/user_live/form.ex:210
#: lib/mv_web/live/custom_field_live/form.ex:64
#, elixir-autogen, elixir-format
msgid "A member with this email already exists. To link with a different member, please change one of the email addresses first."
msgstr ""
#: lib/mv_web/live/user_live/form.ex:185
#, elixir-autogen, elixir-format
msgid "Available members"
msgstr ""
#: lib/mv_web/live/user_live/form.ex:152
#, elixir-autogen, elixir-format
msgid "Member will be unlinked when you save. Cannot select new member until saved."
msgstr ""
#: lib/mv_web/live/user_live/form.ex:226
#, elixir-autogen, elixir-format
msgid "Save to confirm linking."
msgstr ""
#: lib/mv_web/live/user_live/form.ex:169
#, elixir-autogen, elixir-format
msgid "Search for a member to link..."
msgstr ""
#: lib/mv_web/live/user_live/form.ex:173
#, elixir-autogen, elixir-format
msgid "Search for member to link"
msgstr ""
#: lib/mv_web/live/user_live/form.ex:223
#, elixir-autogen, elixir-format
msgid "Selected"
msgstr ""
#: lib/mv_web/live/user_live/form.ex:143
#, elixir-autogen, elixir-format
msgid "Unlink Member"
msgstr ""
#: lib/mv_web/live/user_live/form.ex:152
#, elixir-autogen, elixir-format
msgid "Unlinking scheduled"
msgstr ""
#: lib/mv_web/live/user_live/form.ex:342
#, elixir-autogen, elixir-format
msgid "Failed to link member: %{error}"
msgid "Show in overview"
msgstr ""

View file

@ -11,13 +11,13 @@ msgstr ""
"Language: en\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
#: lib/mv_web/components/core_components.ex:339
#: lib/mv_web/components/core_components.ex:356
#, elixir-autogen, elixir-format
msgid "Actions"
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex:200
#: lib/mv_web/live/user_live/index.html.heex:72
#: lib/mv_web/live/member_live/index.html.heex:202
#: lib/mv_web/live/user_live/index.html.heex:65
#, elixir-autogen, elixir-format
msgid "Are you sure?"
msgstr ""
@ -29,21 +29,21 @@ msgid "Attempting to reconnect"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:54
#: lib/mv_web/live/member_live/index.html.heex:145
#: lib/mv_web/live/member_live/index.html.heex:148
#: lib/mv_web/live/member_live/show.ex:59
#, elixir-autogen, elixir-format
msgid "City"
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex:202
#: lib/mv_web/live/user_live/index.html.heex:74
#: lib/mv_web/live/member_live/index.html.heex:204
#: lib/mv_web/live/user_live/index.html.heex:67
#, elixir-autogen, elixir-format
msgid "Delete"
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex:194
#: lib/mv_web/live/user_live/form.ex:251
#: lib/mv_web/live/user_live/index.html.heex:66
#: lib/mv_web/live/member_live/index.html.heex:196
#: lib/mv_web/live/user_live/form.ex:141
#: lib/mv_web/live/user_live/index.html.heex:59
#, elixir-autogen, elixir-format
msgid "Edit"
msgstr ""
@ -55,7 +55,7 @@ msgid "Edit Member"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:47
#: lib/mv_web/live/member_live/index.html.heex:77
#: lib/mv_web/live/member_live/index.html.heex:80
#: lib/mv_web/live/member_live/show.ex:50
#: lib/mv_web/live/user_live/form.ex:46
#: lib/mv_web/live/user_live/index.html.heex:44
@ -71,7 +71,7 @@ msgid "First Name"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:51
#: lib/mv_web/live/member_live/index.html.heex:179
#: lib/mv_web/live/member_live/index.html.heex:182
#: lib/mv_web/live/member_live/show.ex:56
#, elixir-autogen, elixir-format
msgid "Join Date"
@ -88,8 +88,8 @@ msgstr ""
msgid "New Member"
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex:191
#: lib/mv_web/live/user_live/index.html.heex:63
#: lib/mv_web/live/member_live/index.html.heex:193
#: lib/mv_web/live/user_live/index.html.heex:56
#, elixir-autogen, elixir-format
msgid "Show"
msgstr ""
@ -122,7 +122,7 @@ msgid "Exit Date"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:56
#: lib/mv_web/live/member_live/index.html.heex:111
#: lib/mv_web/live/member_live/index.html.heex:114
#: lib/mv_web/live/member_live/show.ex:61
#, elixir-autogen, elixir-format
msgid "House Number"
@ -141,14 +141,14 @@ msgid "Paid"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:50
#: lib/mv_web/live/member_live/index.html.heex:162
#: lib/mv_web/live/member_live/index.html.heex:165
#: lib/mv_web/live/member_live/show.ex:55
#, elixir-autogen, elixir-format
msgid "Phone Number"
msgstr ""
#: lib/mv_web/live/member_live/form.ex:57
#: lib/mv_web/live/member_live/index.html.heex:128
#: lib/mv_web/live/member_live/index.html.heex:131
#: lib/mv_web/live/member_live/show.ex:62
#, elixir-autogen, elixir-format
msgid "Postal Code"
@ -159,7 +159,7 @@ msgstr ""
msgid "Save Member"
msgstr ""
#: lib/mv_web/live/custom_field_live/form.ex:64
#: lib/mv_web/live/custom_field_live/form.ex:66
#: lib/mv_web/live/custom_field_value_live/form.ex:74
#: lib/mv_web/live/member_live/form.ex:79
#: lib/mv_web/live/user_live/form.ex:234
@ -168,7 +168,7 @@ msgid "Saving..."
msgstr ""
#: lib/mv_web/live/member_live/form.ex:55
#: lib/mv_web/live/member_live/index.html.heex:94
#: lib/mv_web/live/member_live/index.html.heex:97
#: lib/mv_web/live/member_live/show.ex:60
#, elixir-autogen, elixir-format
msgid "Street"
@ -184,6 +184,7 @@ msgstr ""
msgid "Id"
msgstr ""
#: lib/mv_web/live/member_live/index/formatter.ex:65
#: lib/mv_web/live/member_live/show.ex:53
#, elixir-autogen, elixir-format
msgid "No"
@ -199,19 +200,20 @@ msgstr ""
msgid "This is a member record from your database."
msgstr ""
#: lib/mv_web/live/member_live/index/formatter.ex:64
#: lib/mv_web/live/member_live/show.ex:53
#, elixir-autogen, elixir-format
msgid "Yes"
msgstr ""
#: lib/mv_web/live/custom_field_live/form.ex:108
#: lib/mv_web/live/custom_field_live/form.ex:110
#: lib/mv_web/live/custom_field_value_live/form.ex:233
#: lib/mv_web/live/member_live/form.ex:138
#, elixir-autogen, elixir-format
msgid "create"
msgstr ""
#: lib/mv_web/live/custom_field_live/form.ex:109
#: lib/mv_web/live/custom_field_live/form.ex:111
#: lib/mv_web/live/custom_field_value_live/form.ex:234
#: lib/mv_web/live/member_live/form.ex:139
#, elixir-autogen, elixir-format
@ -253,7 +255,7 @@ msgstr ""
msgid "Your password has successfully been reset"
msgstr ""
#: lib/mv_web/live/custom_field_live/form.ex:67
#: lib/mv_web/live/custom_field_live/form.ex:69
#: lib/mv_web/live/custom_field_live/index.ex:120
#: lib/mv_web/live/custom_field_value_live/form.ex:77
#: lib/mv_web/live/member_live/form.ex:82
@ -267,7 +269,7 @@ msgstr ""
msgid "Choose a member"
msgstr ""
#: lib/mv_web/live/custom_field_live/form.ex:60
#: lib/mv_web/live/custom_field_live/form.ex:61
#, elixir-autogen, elixir-format
msgid "Description"
msgstr ""
@ -287,7 +289,7 @@ msgstr ""
msgid "ID"
msgstr ""
#: lib/mv_web/live/custom_field_live/form.ex:61
#: lib/mv_web/live/custom_field_live/form.ex:62
#, elixir-autogen, elixir-format
msgid "Immutable"
msgstr ""
@ -309,13 +311,13 @@ msgid "Member"
msgstr ""
#: lib/mv_web/components/layouts/navbar.ex:19
#: lib/mv_web/live/member_live/index.ex:39
#: lib/mv_web/live/member_live/index.ex:57
#: lib/mv_web/live/member_live/index.html.heex:3
#, elixir-autogen, elixir-format
msgid "Members"
msgstr ""
#: lib/mv_web/live/custom_field_live/form.ex:50
#: lib/mv_web/live/custom_field_live/form.ex:51
#, elixir-autogen, elixir-format
msgid "Name"
msgstr ""
@ -358,17 +360,17 @@ msgstr ""
msgid "Profil"
msgstr ""
#: lib/mv_web/live/custom_field_live/form.ex:62
#: lib/mv_web/live/custom_field_live/form.ex:63
#, elixir-autogen, elixir-format
msgid "Required"
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex:34
#: lib/mv_web/live/member_live/index.html.heex:37
#, elixir-autogen, elixir-format
msgid "Select all members"
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex:48
#: lib/mv_web/live/member_live/index.html.heex:51
#, elixir-autogen, elixir-format
msgid "Select member"
msgstr ""
@ -414,7 +416,7 @@ msgstr ""
msgid "Value"
msgstr ""
#: lib/mv_web/live/custom_field_live/form.ex:55
#: lib/mv_web/live/custom_field_live/form.ex:56
#, elixir-autogen, elixir-format
msgid "Value type"
msgstr ""
@ -570,7 +572,7 @@ msgstr ""
msgid "Click to sort"
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex:60
#: lib/mv_web/live/member_live/index.html.heex:63
#, elixir-autogen, elixir-format, fuzzy
msgid "First name"
msgstr ""
@ -622,7 +624,7 @@ msgstr ""
msgid "Custom field"
msgstr ""
#: lib/mv_web/live/custom_field_live/form.ex:115
#: lib/mv_web/live/custom_field_live/form.ex:117
#, elixir-autogen, elixir-format
msgid "Custom field %{action} successfully"
msgstr ""
@ -637,7 +639,7 @@ msgstr ""
msgid "Please select a custom field first"
msgstr ""
#: lib/mv_web/live/custom_field_live/form.ex:65
#: lib/mv_web/live/custom_field_live/form.ex:67
#, elixir-autogen, elixir-format
msgid "Save Custom field"
msgstr ""
@ -647,7 +649,7 @@ msgstr ""
msgid "Save Custom field value"
msgstr ""
#: lib/mv_web/live/custom_field_live/form.ex:45
#: lib/mv_web/live/custom_field_live/form.ex:46
#, elixir-autogen, elixir-format, fuzzy
msgid "Use this form to manage custom_field records in your database."
msgstr ""
@ -748,3 +750,12 @@ msgstr ""
#, elixir-autogen, elixir-format
msgid "Failed to link member: %{error}"
msgstr ""
#: lib/mv_web/live/custom_field_live/form.ex:64
#, elixir-autogen, elixir-format
msgid "Show in overview"
msgstr ""
#~ #: lib/mv_web/live/custom_field_live/index.ex:97
#~ #, elixir-autogen, elixir-format
#~ msgid "To confirm deletion, please enter the custom field slug:"
#~ msgstr ""

View file

@ -0,0 +1,21 @@
defmodule Mv.Repo.Migrations.AddShowInOverviewToCustomFields do
@moduledoc """
Updates resources based on their most recent snapshots.
This file was autogenerated with `mix ash_postgres.generate_migrations`
"""
use Ecto.Migration
def up do
alter table(:custom_fields) do
add :show_in_overview, :boolean, null: false, default: true
end
end
def down do
alter table(:custom_fields) do
remove :show_in_overview
end
end
end

View file

@ -0,0 +1,118 @@
{
"attributes": [
{
"allow_nil?": false,
"default": "fragment(\"gen_random_uuid()\")",
"generated?": false,
"precision": null,
"primary_key?": true,
"references": null,
"scale": null,
"size": null,
"source": "id",
"type": "uuid"
},
{
"allow_nil?": false,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "name",
"type": "text"
},
{
"allow_nil?": false,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "value_type",
"type": "text"
},
{
"allow_nil?": true,
"default": "nil",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "description",
"type": "text"
},
{
"allow_nil?": false,
"default": "false",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "immutable",
"type": "boolean"
},
{
"allow_nil?": false,
"default": "false",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "required",
"type": "boolean"
},
{
"allow_nil?": false,
"default": "false",
"generated?": false,
"precision": null,
"primary_key?": false,
"references": null,
"scale": null,
"size": null,
"source": "show_in_overview",
"type": "boolean"
}
],
"base_filter": null,
"check_constraints": [],
"custom_indexes": [],
"custom_statements": [],
"has_create_action": true,
"hash": "9FBFC42DA896058F88DEDAE774614919222BF2EF2F8CB27386D02C2CE67F03DE",
"identities": [
{
"all_tenants?": false,
"base_filter": null,
"index_name": "custom_fields_unique_name_index",
"keys": [
{
"type": "atom",
"value": "name"
}
],
"name": "unique_name",
"nils_distinct?": true,
"where": null
}
],
"multitenancy": {
"attribute": null,
"global": null,
"strategy": null
},
"repo": "Elixir.Mv.Repo",
"schema": null,
"table": "custom_fields"
}

View file

@ -0,0 +1,77 @@
defmodule Mv.Membership.CustomFieldShowInOverviewTest do
@moduledoc """
Tests for CustomField show_in_overview attribute.
Tests cover:
- Creating custom fields with show_in_overview: true
- Creating custom fields with show_in_overview: false (default)
- Updating show_in_overview to true
- Updating show_in_overview to false
"""
use Mv.DataCase, async: true
alias Mv.Membership.CustomField
describe "show_in_overview attribute" do
test "creates custom field with show_in_overview: true" do
assert {:ok, custom_field} =
CustomField
|> Ash.Changeset.for_create(:create, %{
name: "test_field_show",
value_type: :string,
show_in_overview: true
})
|> Ash.create()
assert custom_field.show_in_overview == true
end
test "creates custom field with show_in_overview: true (default)" do
assert {:ok, custom_field} =
CustomField
|> Ash.Changeset.for_create(:create, %{
name: "test_field_hide",
value_type: :string
})
|> Ash.create()
assert custom_field.show_in_overview == true
end
test "updates show_in_overview to true" do
{:ok, custom_field} =
CustomField
|> Ash.Changeset.for_create(:create, %{
name: "test_field_update",
value_type: :string,
show_in_overview: false
})
|> Ash.create()
assert {:ok, updated_field} =
custom_field
|> Ash.Changeset.for_update(:update, %{show_in_overview: true})
|> Ash.update()
assert updated_field.show_in_overview == true
end
test "updates show_in_overview to false" do
{:ok, custom_field} =
CustomField
|> Ash.Changeset.for_create(:create, %{
name: "test_field_update2",
value_type: :string,
show_in_overview: true
})
|> Ash.create()
assert {:ok, updated_field} =
custom_field
|> Ash.Changeset.for_update(:update, %{show_in_overview: false})
|> Ash.update()
assert updated_field.show_in_overview == false
end
end
end

View file

@ -0,0 +1,113 @@
defmodule MvWeb.MemberLive.IndexCustomFieldsAccessibilityTest do
@moduledoc """
Accessibility tests for custom field columns in the member overview.
Tests cover:
- SortHeaderComponent for custom fields has correct ARIA labels
- Tab navigation works for custom field columns
- Screen reader announcements for sorting
"""
use MvWeb.ConnCase, async: true
import Phoenix.LiveViewTest
require Ash.Query
alias Mv.Membership.{CustomField, CustomFieldValue, Member}
setup do
# Create test member
{:ok, member} =
Member
|> Ash.Changeset.for_create(:create_member, %{
first_name: "Alice",
last_name: "Anderson",
email: "alice@example.com"
})
|> Ash.create()
# Create custom field with show_in_overview: true
{:ok, field} =
CustomField
|> Ash.Changeset.for_create(:create, %{
name: "membership_number",
value_type: :string,
show_in_overview: true
})
|> Ash.create()
# Create custom field value
{:ok, _cfv} =
CustomFieldValue
|> Ash.Changeset.for_create(:create, %{
member_id: member.id,
custom_field_id: field.id,
value: %{"_union_type" => "string", "_union_value" => "A001"}
})
|> Ash.create()
%{member: member, field: field}
end
test "sort header component for custom fields has correct ARIA labels", %{
conn: conn,
field: field
} do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members")
# Check that the sort button has aria-label
assert html =~ ~r/aria-label=["']Click to sort["']/i or
html =~ ~r/aria-label=["'].*sort.*["']/i
# Check that data-testid is present for testing
assert html =~ ~r/data-testid=["']custom_field_#{field.id}["']/
end
test "sort header component shows correct ARIA label when sorted ascending", %{
conn: conn,
field: field
} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} =
live(conn, "/members?query=&sort_field=custom_field_#{field.id}&sort_order=asc")
html = render(view)
# Check that aria-label indicates ascending sort
assert html =~ ~r/aria-label=["'].*ascending.*["']/i
end
test "sort header component shows correct ARIA label when sorted descending", %{
conn: conn,
field: field
} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} =
live(conn, "/members?query=&sort_field=custom_field_#{field.id}&sort_order=desc")
html = render(view)
# Check that aria-label indicates descending sort
assert html =~ ~r/aria-label=["'].*descending.*["']/i
end
test "custom field column header is keyboard accessible", %{conn: conn, field: field} do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members")
# Check that the sort button is a button element (keyboard accessible)
assert html =~ ~r/<button[^>]*data-testid=["']custom_field_#{field.id}["']/
# Button should not have tabindex="-1" (which would remove from tab order)
refute html =~ ~r/tabindex=["']-1["']/
end
test "custom field column header has proper semantic structure", %{conn: conn, field: field} do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members")
# Check that custom field name is displayed in the header
assert html =~ field.name
end
end

View file

@ -0,0 +1,262 @@
defmodule MvWeb.MemberLive.IndexCustomFieldsDisplayTest do
@moduledoc """
Tests for displaying custom fields in the member overview.
Tests cover:
- Custom fields with show_in_overview: true are displayed
- Custom fields with show_in_overview: false are not displayed
- Multiple custom fields with show_in_overview: true are all displayed
- Custom field values are correctly formatted for different types
- Members without custom field values show empty cell or "-"
"""
use MvWeb.ConnCase, async: true
import Phoenix.LiveViewTest
require Ash.Query
alias Mv.Membership.{CustomField, CustomFieldValue, Member}
setup do
# Create test members
{:ok, member1} =
Member
|> Ash.Changeset.for_create(:create_member, %{
first_name: "Alice",
last_name: "Anderson",
email: "alice@example.com"
})
|> Ash.create()
{:ok, member2} =
Member
|> Ash.Changeset.for_create(:create_member, %{
first_name: "Bob",
last_name: "Brown",
email: "bob@example.com"
})
|> Ash.create()
# Create custom fields
{:ok, field_show_string} =
CustomField
|> Ash.Changeset.for_create(:create, %{
name: "phone_mobile",
value_type: :string,
show_in_overview: true
})
|> Ash.create()
{:ok, field_hide} =
CustomField
|> Ash.Changeset.for_create(:create, %{
name: "internal_note",
value_type: :string,
show_in_overview: false
})
|> Ash.create()
{:ok, field_show_integer} =
CustomField
|> Ash.Changeset.for_create(:create, %{
name: "membership_number",
value_type: :integer,
show_in_overview: true
})
|> Ash.create()
{:ok, field_show_boolean} =
CustomField
|> Ash.Changeset.for_create(:create, %{
name: "newsletter",
value_type: :boolean,
show_in_overview: true
})
|> Ash.create()
{:ok, field_show_date} =
CustomField
|> Ash.Changeset.for_create(:create, %{
name: "birthday",
value_type: :date,
show_in_overview: true
})
|> Ash.create()
{:ok, field_show_email} =
CustomField
|> Ash.Changeset.for_create(:create, %{
name: "secondary_email",
value_type: :email,
show_in_overview: true
})
|> Ash.create()
# Create custom field values for member1
{:ok, _cfv1} =
CustomFieldValue
|> Ash.Changeset.for_create(:create, %{
member_id: member1.id,
custom_field_id: field_show_string.id,
value: %{"_union_type" => "string", "_union_value" => "+49123456789"}
})
|> Ash.create()
{:ok, _cfv2} =
CustomFieldValue
|> Ash.Changeset.for_create(:create, %{
member_id: member1.id,
custom_field_id: field_show_integer.id,
value: %{"_union_type" => "integer", "_union_value" => 12_345}
})
|> Ash.create()
{:ok, _cfv3} =
CustomFieldValue
|> Ash.Changeset.for_create(:create, %{
member_id: member1.id,
custom_field_id: field_show_boolean.id,
value: %{"_union_type" => "boolean", "_union_value" => true}
})
|> Ash.create()
{:ok, _cfv4} =
CustomFieldValue
|> Ash.Changeset.for_create(:create, %{
member_id: member1.id,
custom_field_id: field_show_date.id,
value: %{"_union_type" => "date", "_union_value" => ~D[1990-05-15]}
})
|> Ash.create()
{:ok, _cfv5} =
CustomFieldValue
|> Ash.Changeset.for_create(:create, %{
member_id: member1.id,
custom_field_id: field_show_email.id,
value: %{"_union_type" => "email", "_union_value" => "alice.private@example.com"}
})
|> Ash.create()
# Create hidden custom field value (should not be displayed)
{:ok, _cfv_hidden} =
CustomFieldValue
|> Ash.Changeset.for_create(:create, %{
member_id: member1.id,
custom_field_id: field_hide.id,
value: %{"_union_type" => "string", "_union_value" => "Internal note"}
})
|> Ash.create()
%{
member1: member1,
member2: member2,
field_show_string: field_show_string,
field_hide: field_hide,
field_show_integer: field_show_integer,
field_show_boolean: field_show_boolean,
field_show_date: field_show_date,
field_show_email: field_show_email
}
end
test "displays custom field with show_in_overview: true", %{
conn: conn,
member1: _member1,
field_show_string: field
} do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members")
# Check that the custom field column header is displayed
assert html =~ field.name
# Check that the value is displayed
assert html =~ "+49123456789"
end
test "does not display custom field with show_in_overview: false", %{
conn: conn,
member1: _member1,
field_hide: field
} do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members")
# Check that the hidden custom field column header is NOT displayed
refute html =~ field.name
# Check that the value is NOT displayed
refute html =~ "Internal note"
end
test "displays multiple custom fields with show_in_overview: true", %{
conn: conn,
field_show_string: field_string,
field_show_integer: field_integer,
field_show_boolean: field_boolean
} do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members")
# Check that all visible custom field column headers are displayed
assert html =~ field_string.name
assert html =~ field_integer.name
assert html =~ field_boolean.name
end
test "formats string custom field values correctly", %{conn: conn, member1: _member1} do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members")
assert html =~ "+49123456789"
end
test "formats integer custom field values correctly", %{conn: conn, member1: _member1} do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members")
assert html =~ "12345"
end
test "formats boolean custom field values correctly", %{conn: conn, member1: _member1} do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members")
# Boolean should be displayed as "Yes" or "No" or similar
# Check for true representation
assert html =~ "true" or html =~ "Yes" or html =~ "Ja"
end
test "formats date custom field values correctly", %{conn: conn, member1: _member1} do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members")
# Date should be displayed in readable format
assert html =~ "1990" or html =~ "1990-05-15" or html =~ "15.05.1990"
end
test "formats email custom field values correctly", %{conn: conn, member1: _member1} do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members")
assert html =~ "alice.private@example.com"
end
test "shows empty cell or placeholder for members without custom field values", %{
conn: conn,
member2: _member2,
field_show_string: field
} do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members")
# The custom field column should exist
assert html =~ field.name
# Member2 should have an empty cell for this field
# We check that member2's row exists but doesn't have the value
assert html =~ "Bob 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 "-"
assert html =~ "+49123456789"
end
end

View file

@ -0,0 +1,173 @@
defmodule MvWeb.MemberLive.IndexCustomFieldsEdgeCasesTest do
@moduledoc """
Edge case tests for custom fields in the member overview.
Tests cover:
- Custom field without values (all members have no value)
- Very long custom field values are correctly displayed
"""
use MvWeb.ConnCase, async: true
import Phoenix.LiveViewTest
require Ash.Query
alias Mv.Membership.{CustomField, Member}
test "displays custom field column even when no members have values", %{conn: conn} do
# Create test members without custom field values
{:ok, _member1} =
Member
|> Ash.Changeset.for_create(:create_member, %{
first_name: "Alice",
last_name: "Anderson",
email: "alice@example.com"
})
|> Ash.create()
{:ok, _member2} =
Member
|> Ash.Changeset.for_create(:create_member, %{
first_name: "Bob",
last_name: "Brown",
email: "bob@example.com"
})
|> Ash.create()
# Create custom field with show_in_overview: true but no values
{:ok, field} =
CustomField
|> Ash.Changeset.for_create(:create, %{
name: "membership_number",
value_type: :string,
show_in_overview: true
})
|> Ash.create()
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members")
# Check that the custom field column header is still displayed
assert html =~ field.name
end
test "displays very long custom field values correctly", %{conn: conn} do
# Create test member
{:ok, member} =
Member
|> Ash.Changeset.for_create(:create_member, %{
first_name: "Alice",
last_name: "Anderson",
email: "alice@example.com"
})
|> Ash.create()
# Create custom field
{:ok, field} =
CustomField
|> Ash.Changeset.for_create(:create, %{
name: "long_note",
value_type: :string,
show_in_overview: true
})
|> Ash.create()
# Create very long value (but within limits)
long_value = String.duplicate("A", 500)
{:ok, _cfv} =
Mv.Membership.CustomFieldValue
|> Ash.Changeset.for_create(:create, %{
member_id: member.id,
custom_field_id: field.id,
value: %{"_union_type" => "string", "_union_value" => long_value}
})
|> Ash.create()
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members")
# Check that the value is displayed (may be truncated in UI, but should be present)
# We check for at least part of the value
assert html =~ "A" or html =~ long_value
end
test "handles multiple custom fields with show_in_overview correctly", %{conn: conn} do
# Create test member
{:ok, member} =
Member
|> Ash.Changeset.for_create(:create_member, %{
first_name: "Alice",
last_name: "Anderson",
email: "alice@example.com"
})
|> Ash.create()
# Create multiple custom fields with show_in_overview: true
{:ok, field1} =
CustomField
|> Ash.Changeset.for_create(:create, %{
name: "field1",
value_type: :string,
show_in_overview: true
})
|> Ash.create()
{:ok, field2} =
CustomField
|> Ash.Changeset.for_create(:create, %{
name: "field2",
value_type: :string,
show_in_overview: true
})
|> Ash.create()
{:ok, field3} =
CustomField
|> Ash.Changeset.for_create(:create, %{
name: "field3",
value_type: :string,
show_in_overview: true
})
|> Ash.create()
# Create values for all fields
{:ok, _cfv1} =
Mv.Membership.CustomFieldValue
|> Ash.Changeset.for_create(:create, %{
member_id: member.id,
custom_field_id: field1.id,
value: %{"_union_type" => "string", "_union_value" => "Value1"}
})
|> Ash.create()
{:ok, _cfv2} =
Mv.Membership.CustomFieldValue
|> Ash.Changeset.for_create(:create, %{
member_id: member.id,
custom_field_id: field2.id,
value: %{"_union_type" => "string", "_union_value" => "Value2"}
})
|> Ash.create()
{:ok, _cfv3} =
Mv.Membership.CustomFieldValue
|> Ash.Changeset.for_create(:create, %{
member_id: member.id,
custom_field_id: field3.id,
value: %{"_union_type" => "string", "_union_value" => "Value3"}
})
|> Ash.create()
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members")
# Check that all custom field columns are displayed
assert html =~ field1.name
assert html =~ field2.name
assert html =~ field3.name
# Check that all values are displayed
assert html =~ "Value1"
assert html =~ "Value2"
assert html =~ "Value3"
end
end

View file

@ -0,0 +1,459 @@
defmodule MvWeb.MemberLive.IndexCustomFieldsSortingTest do
@moduledoc """
Tests for sorting by custom fields in the member overview.
Tests cover:
- Sorting by custom field (ascending)
- Sorting by custom field (descending)
- Sorting by custom field works with search
- Sorting by custom field works with URL parameters
- Sorting by custom field works with other columns
"""
use MvWeb.ConnCase, async: true
import Phoenix.LiveViewTest
require Ash.Query
alias Mv.Membership.{CustomField, CustomFieldValue, Member}
setup do
# Create test members
{:ok, member1} =
Member
|> Ash.Changeset.for_create(:create_member, %{
first_name: "Alice",
last_name: "Anderson",
email: "alice@example.com"
})
|> Ash.create()
{:ok, member2} =
Member
|> Ash.Changeset.for_create(:create_member, %{
first_name: "Bob",
last_name: "Brown",
email: "bob@example.com"
})
|> Ash.create()
{:ok, member3} =
Member
|> Ash.Changeset.for_create(:create_member, %{
first_name: "Charlie",
last_name: "Clark",
email: "charlie@example.com"
})
|> Ash.create()
# Create custom field with show_in_overview: true
{:ok, field_string} =
CustomField
|> Ash.Changeset.for_create(:create, %{
name: "membership_number",
value_type: :string,
show_in_overview: true
})
|> Ash.create()
{:ok, field_integer} =
CustomField
|> Ash.Changeset.for_create(:create, %{
name: "priority",
value_type: :integer,
show_in_overview: true
})
|> Ash.create()
# Create custom field values
{:ok, _cfv1} =
CustomFieldValue
|> Ash.Changeset.for_create(:create, %{
member_id: member1.id,
custom_field_id: field_string.id,
value: %{"_union_type" => "string", "_union_value" => "A001"}
})
|> Ash.create()
{:ok, _cfv2} =
CustomFieldValue
|> Ash.Changeset.for_create(:create, %{
member_id: member2.id,
custom_field_id: field_string.id,
value: %{"_union_type" => "string", "_union_value" => "C003"}
})
|> Ash.create()
{:ok, _cfv3} =
CustomFieldValue
|> Ash.Changeset.for_create(:create, %{
member_id: member3.id,
custom_field_id: field_string.id,
value: %{"_union_type" => "string", "_union_value" => "B002"}
})
|> Ash.create()
{:ok, _cfv4} =
CustomFieldValue
|> Ash.Changeset.for_create(:create, %{
member_id: member1.id,
custom_field_id: field_integer.id,
value: %{"_union_type" => "integer", "_union_value" => 10}
})
|> Ash.create()
{:ok, _cfv5} =
CustomFieldValue
|> Ash.Changeset.for_create(:create, %{
member_id: member2.id,
custom_field_id: field_integer.id,
value: %{"_union_type" => "integer", "_union_value" => 30}
})
|> Ash.create()
{:ok, _cfv6} =
CustomFieldValue
|> Ash.Changeset.for_create(:create, %{
member_id: member3.id,
custom_field_id: field_integer.id,
value: %{"_union_type" => "integer", "_union_value" => 20}
})
|> Ash.create()
%{
member1: member1,
member2: member2,
member3: member3,
field_string: field_string,
field_integer: field_integer
}
end
test "sorts by custom field ascending", %{conn: conn, field_string: field} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Click on custom field column header to sort
view
|> element("[data-testid='custom_field_#{field.id}']")
|> render_click()
# Check URL was updated
assert_patch(view, "/members?query=&sort_field=custom_field_#{field.id}&sort_order=asc")
# Verify sort state
assert has_element?(view, "[data-testid='custom_field_#{field.id}'][aria-label='ascending']")
end
test "sorts by custom field descending", %{conn: conn, field_string: field} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members?sort_field=custom_field_#{field.id}&sort_order=asc")
# Click again to toggle to descending
view
|> element("[data-testid='custom_field_#{field.id}']")
|> render_click()
# Check URL was updated
assert_patch(view, "/members?query=&sort_field=custom_field_#{field.id}&sort_order=desc")
# Verify sort state
assert has_element?(view, "[data-testid='custom_field_#{field.id}'][aria-label='descending']")
end
test "sorting by custom field works with search", %{conn: conn, field_string: field} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members?query=Alice")
# Click on custom field column header to sort
view
|> element("[data-testid='custom_field_#{field.id}']")
|> render_click()
# Check URL maintains search query
assert_patch(view, "/members?query=Alice&sort_field=custom_field_#{field.id}&sort_order=asc")
end
test "sorting by custom field works with URL parameters", %{conn: conn, field_string: field} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} =
live(conn, "/members?query=&sort_field=custom_field_#{field.id}&sort_order=desc")
# Check that the sort state is correctly applied
assert has_element?(view, "[data-testid='custom_field_#{field.id}'][aria-label='descending']")
end
test "clicking different custom field column resets order to ascending", %{
conn: conn,
field_string: field_string,
field_integer: field_integer
} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} =
live(conn, "/members?query=&sort_field=custom_field_#{field_string.id}&sort_order=desc")
# Click on a different custom field column
view
|> element("[data-testid='custom_field_#{field_integer.id}']")
|> render_click()
assert_patch(
view,
"/members?query=&sort_field=custom_field_#{field_integer.id}&sort_order=asc"
)
end
test "clicking regular column after custom field column works", %{
conn: conn,
field_string: field
} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} =
live(conn, "/members?query=&sort_field=custom_field_#{field.id}&sort_order=desc")
# Click on email column
view
|> element("[data-testid='email']")
|> render_click()
assert_patch(view, "/members?query=&sort_field=email&sort_order=asc")
end
test "clicking custom field column after regular column works", %{
conn: conn,
field_string: field
} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members?query=&sort_field=email&sort_order=desc")
# Click on custom field column
view
|> element("[data-testid='custom_field_#{field.id}']")
|> render_click()
assert_patch(view, "/members?query=&sort_field=custom_field_#{field.id}&sort_order=asc")
end
test "NULL values and empty strings are always sorted last (ASC)", %{conn: conn} do
# Create additional members with NULL and empty string values
{:ok, member_with_value} =
Member
|> Ash.Changeset.for_create(:create_member, %{
first_name: "WithValue",
last_name: "Test",
email: "withvalue@example.com"
})
|> Ash.create()
{:ok, member_with_empty} =
Member
|> Ash.Changeset.for_create(:create_member, %{
first_name: "WithEmpty",
last_name: "Test",
email: "withempty@example.com"
})
|> Ash.create()
{:ok, member_with_null} =
Member
|> Ash.Changeset.for_create(:create_member, %{
first_name: "WithNull",
last_name: "Test",
email: "withnull@example.com"
})
|> Ash.create()
{:ok, member_with_another_value} =
Member
|> Ash.Changeset.for_create(:create_member, %{
first_name: "AnotherValue",
last_name: "Test",
email: "another@example.com"
})
|> Ash.create()
# Create custom field
{:ok, field} =
CustomField
|> Ash.Changeset.for_create(:create, %{
name: "test_field",
value_type: :string,
show_in_overview: true
})
|> Ash.create()
# Create values: one with actual value, one with empty string, one with NULL (no value), another with value
{:ok, _cfv1} =
CustomFieldValue
|> Ash.Changeset.for_create(:create, %{
member_id: member_with_value.id,
custom_field_id: field.id,
value: %{"_union_type" => "string", "_union_value" => "Zebra"}
})
|> Ash.create()
{:ok, _cfv2} =
CustomFieldValue
|> Ash.Changeset.for_create(:create, %{
member_id: member_with_empty.id,
custom_field_id: field.id,
value: %{"_union_type" => "string", "_union_value" => ""}
})
|> Ash.create()
# member_with_null has no custom field value (NULL)
{:ok, _cfv3} =
CustomFieldValue
|> Ash.Changeset.for_create(:create, %{
member_id: member_with_another_value.id,
custom_field_id: field.id,
value: %{"_union_type" => "string", "_union_value" => "Apple"}
})
|> Ash.create()
conn = conn_with_oidc_user(conn)
{:ok, view, _html} =
live(conn, "/members?query=&sort_field=custom_field_#{field.id}&sort_order=asc")
html = render(view)
# Find positions of member first names in the HTML to verify sort order
apple_pos = :binary.match(html, member_with_another_value.first_name)
zebra_pos = :binary.match(html, member_with_value.first_name)
empty_pos = :binary.match(html, member_with_empty.first_name)
null_pos = :binary.match(html, member_with_null.first_name)
assert apple_pos != :nomatch, "AnotherValue (Apple) should be in HTML"
assert zebra_pos != :nomatch, "WithValue (Zebra) should be in HTML"
assert empty_pos != :nomatch, "WithEmpty should be in HTML"
assert null_pos != :nomatch, "WithNull should be in HTML"
{apple_idx, _} = apple_pos
{zebra_idx, _} = zebra_pos
{empty_idx, _} = empty_pos
{null_idx, _} = null_pos
# In ASC order: Apple should come before Zebra
assert apple_idx < zebra_idx, "Apple should come before Zebra in ASC order"
# NULL and empty should come after all values
assert apple_idx < empty_idx, "Apple should come before empty value"
assert apple_idx < null_idx, "Apple should come before NULL value"
assert zebra_idx < empty_idx, "Zebra should come before empty value"
assert zebra_idx < null_idx, "Zebra should come before NULL value"
end
test "NULL values and empty strings are always sorted last (DESC)", %{conn: conn} do
# Create additional members with NULL and empty string values
{:ok, member_with_value} =
Member
|> Ash.Changeset.for_create(:create_member, %{
first_name: "WithValue",
last_name: "Test",
email: "withvalue@example.com"
})
|> Ash.create()
{:ok, member_with_empty} =
Member
|> Ash.Changeset.for_create(:create_member, %{
first_name: "WithEmpty",
last_name: "Test",
email: "withempty@example.com"
})
|> Ash.create()
{:ok, member_with_null} =
Member
|> Ash.Changeset.for_create(:create_member, %{
first_name: "WithNull",
last_name: "Test",
email: "withnull@example.com"
})
|> Ash.create()
{:ok, member_with_another_value} =
Member
|> Ash.Changeset.for_create(:create_member, %{
first_name: "AnotherValue",
last_name: "Test",
email: "another@example.com"
})
|> Ash.create()
# Create custom field
{:ok, field} =
CustomField
|> Ash.Changeset.for_create(:create, %{
name: "test_field",
value_type: :string,
show_in_overview: true
})
|> Ash.create()
# Create values: one with actual value, one with empty string, one with NULL (no value), another with value
{:ok, _cfv1} =
CustomFieldValue
|> Ash.Changeset.for_create(:create, %{
member_id: member_with_value.id,
custom_field_id: field.id,
value: %{"_union_type" => "string", "_union_value" => "Apple"}
})
|> Ash.create()
{:ok, _cfv2} =
CustomFieldValue
|> Ash.Changeset.for_create(:create, %{
member_id: member_with_empty.id,
custom_field_id: field.id,
value: %{"_union_type" => "string", "_union_value" => ""}
})
|> Ash.create()
# member_with_null has no custom field value (NULL)
{:ok, _cfv3} =
CustomFieldValue
|> Ash.Changeset.for_create(:create, %{
member_id: member_with_another_value.id,
custom_field_id: field.id,
value: %{"_union_type" => "string", "_union_value" => "Zebra"}
})
|> Ash.create()
conn = conn_with_oidc_user(conn)
{:ok, view, _html} =
live(conn, "/members?query=&sort_field=custom_field_#{field.id}&sort_order=desc")
html = render(view)
# Find positions of member first names in the HTML to verify sort order
apple_pos = :binary.match(html, member_with_value.first_name)
zebra_pos = :binary.match(html, member_with_another_value.first_name)
empty_pos = :binary.match(html, member_with_empty.first_name)
null_pos = :binary.match(html, member_with_null.first_name)
assert apple_pos != :nomatch, "WithValue (Apple) should be in HTML"
assert zebra_pos != :nomatch, "AnotherValue (Zebra) should be in HTML"
assert empty_pos != :nomatch, "WithEmpty should be in HTML"
assert null_pos != :nomatch, "WithNull should be in HTML"
{apple_idx, _} = apple_pos
{zebra_idx, _} = zebra_pos
{empty_idx, _} = empty_pos
{null_idx, _} = null_pos
# In DESC order: Zebra should come before Apple
assert zebra_idx < apple_idx, "Zebra should come before Apple in DESC order"
# NULL and empty should come after all values
assert zebra_idx < empty_idx, "Zebra should come before empty value"
assert zebra_idx < null_idx, "Zebra should come before NULL value"
assert apple_idx < empty_idx, "Apple should come before empty value"
assert apple_idx < null_idx, "Apple should come before NULL value"
end
end