Compare commits

...

7 commits

Author SHA1 Message Date
09c580e02d translate: add translation
All checks were successful
continuous-integration/drone/push Build is passing
2025-11-27 14:14:53 +01:00
631cf23a0f formatting
All checks were successful
continuous-integration/drone/push Build is passing
2025-11-27 14:10:27 +01:00
c974be9ee2 feat: add dynamic cols to member overview and checkbox to form
Some checks failed
continuous-integration/drone/push Build is failing
2025-11-26 18:18:58 +01:00
1c8b5df105 feat: adds dynamic cols to table core component 2025-11-26 18:18:27 +01:00
ad2ab7b1d9 chore: show in overview attribute to custom field 2025-11-26 18:15:14 +01:00
d9afdc90ed test: added tests 2025-11-26 18:14:29 +01:00
6f6808d2ad chore: add migration for show in overview flag 2025-11-26 14:42:19 +01:00
17 changed files with 1894 additions and 124 deletions

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,11 @@ defmodule MvWeb.MemberLive.Index do
"""
use MvWeb, :live_view
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 +39,19 @@ defmodule MvWeb.MemberLive.Index do
"""
@impl true
def mount(_params, _session, socket) do
# Load custom fields that should be shown in overview
require Ash.Query
import Ash.Expr
# 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,39 @@ 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 ->
formatted = Formatter.format_custom_field_value(cfv.value, custom_field)
if formatted == "", do: "", else: formatted
end
end
}
end)
assign(socket, :dynamic_cols, dynamic_cols)
end
# -------------------------------------------------------------
# FUNCTIONS
# -------------------------------------------------------------
@ -177,8 +234,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 +254,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 +292,25 @@ 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
# 3. Applies search filter if provided
# 4. Applies sorting (database-level for regular fields, in-memory for custom fields)
# 5. Filters custom field values to only visible ones (reduces memory usage)
#
# Performance Considerations:
# - 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.
# - Memory filtering: Custom field values are filtered after loading to reduce
# memory usage, but all members are still loaded into memory.
# - 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 +328,86 @@ defmodule MvWeb.MemberLive.Index do
:join_date
])
# Load custom field values for visible custom fields
custom_field_ids = Enum.map(socket.assigns.custom_fields_visible, & &1.id)
query = load_custom_field_values(query, custom_field_ids)
# 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)
# Filter custom field values to only visible ones (reduces memory usage)
# Performance: This iterates through all members and their custom_field_values.
# For large datasets (>1000 members), this could be optimized by filtering
# at the database level, but requires more complex Ash queries.
custom_field_ids = MapSet.new(Enum.map(socket.assigns.custom_fields_visible, & &1.id))
members = filter_member_custom_field_values(members, custom_field_ids)
# 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
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
# Load all custom field values with their custom_field relationship
# Note: We filter to visible custom fields after loading to reduce memory usage
# Ash loads relationships efficiently with JOINs, but we only keep visible ones
query
|> Ash.Query.load(custom_field_values: [custom_field: [:id, :name, :value_type]])
end
# Filters custom field values to only visible ones for all members
defp filter_member_custom_field_values(members, custom_field_ids) do
Enum.map(members, fn member ->
filter_single_member_custom_field_values(member, custom_field_ids)
end)
end
# Filters custom field values for a single member
defp filter_single_member_custom_field_values(member, _custom_field_ids)
when not is_list(member.custom_field_values) do
member
end
defp filter_single_member_custom_field_values(member, custom_field_ids) do
filtered_values =
Enum.filter(member.custom_field_values, fn cfv ->
cfv.custom_field_id in custom_field_ids
end)
%{member | custom_field_values: filtered_values}
end
# -------------------------------------------------------------
# Helper Functions
# -------------------------------------------------------------
@ -264,15 +430,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 +463,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 +656,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 +718,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,78 @@
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, _) when is_binary(value) do
# Return empty string if value is empty, otherwise return the value
if String.trim(value) == "", do: "", else: value
end
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, :email, _) when 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,12 +10,12 @@ 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/member_live/index.html.heex:202
#: lib/mv_web/live/user_live/index.html.heex:65
#, elixir-autogen, elixir-format
msgid "Are you sure?"
@ -28,19 +28,19 @@ msgid "Attempting to reconnect"
msgstr "Verbindung wird wiederhergestellt"
#: lib/mv_web/live/member_live/form.ex: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/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/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
@ -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,7 +87,7 @@ msgstr "Nachname"
msgid "New Member"
msgstr "Neues Mitglied"
#: lib/mv_web/live/member_live/index.html.heex:191
#: 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"
@ -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:124
@ -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"
@ -356,17 +358,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"
@ -412,7 +414,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"
@ -565,7 +567,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"
@ -617,7 +619,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}"
@ -632,7 +634,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"
@ -642,7 +644,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."
@ -694,6 +696,11 @@ msgstr "Obigen Text zur Bestätigung eingeben"
msgid "To confirm deletion, please enter this text:"
msgstr "Um die Löschung zu bestätigen, gib bitte folgenden Text ein:"
#: 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:"

View file

@ -11,12 +11,12 @@
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/member_live/index.html.heex:202
#: lib/mv_web/live/user_live/index.html.heex:65
#, elixir-autogen, elixir-format
msgid "Are you sure?"
@ -29,19 +29,19 @@ msgid "Attempting to reconnect"
msgstr ""
#: lib/mv_web/live/member_live/form.ex: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/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/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
@ -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,7 +88,7 @@ msgstr ""
msgid "New Member"
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex:191
#: 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"
@ -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:124
@ -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 ""
@ -357,17 +359,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 ""
@ -413,7 +415,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 ""
@ -566,7 +568,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 ""
@ -618,7 +620,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 ""
@ -633,7 +635,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 ""
@ -643,7 +645,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 ""
@ -694,3 +696,8 @@ msgstr ""
#, elixir-autogen, elixir-format
msgid "To confirm deletion, please enter this text:"
msgstr ""
#: lib/mv_web/live/custom_field_live/form.ex:64
#, elixir-autogen, elixir-format
msgid "Show in overview"
msgstr ""

View file

@ -11,12 +11,12 @@ 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/member_live/index.html.heex:202
#: lib/mv_web/live/user_live/index.html.heex:65
#, elixir-autogen, elixir-format
msgid "Are you sure?"
@ -29,19 +29,19 @@ msgid "Attempting to reconnect"
msgstr ""
#: lib/mv_web/live/member_live/form.ex: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/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/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
@ -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,7 +88,7 @@ msgstr ""
msgid "New Member"
msgstr ""
#: lib/mv_web/live/member_live/index.html.heex:191
#: 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"
@ -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:124
@ -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 ""
@ -357,17 +359,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 ""
@ -413,7 +415,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 ""
@ -566,7 +568,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 ""
@ -618,7 +620,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 ""
@ -633,7 +635,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 ""
@ -643,7 +645,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 ""
@ -695,6 +697,11 @@ msgstr ""
msgid "To confirm deletion, please enter this text:"
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:"

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