diff --git a/Justfile b/Justfile
index b835cf4..2231525 100644
--- a/Justfile
+++ b/Justfile
@@ -1,7 +1,4 @@
set dotenv-load := true
-set export := true
-
-MIX_QUIET := "1"
run: install-dependencies start-database migrate-database seed-database
mix phx.server
diff --git a/lib/membership/member.ex b/lib/membership/member.ex
index b788dc9..8d271d7 100644
--- a/lib/membership/member.ex
+++ b/lib/membership/member.ex
@@ -401,70 +401,6 @@ defmodule Mv.Membership.Member do
identity :unique_email, [:email]
end
- @doc """
- Checks if a member field should be shown in the overview.
-
- Reads the visibility configuration from Settings resource. If a field is not
- configured in settings, it defaults to `true` (visible).
-
- ## Parameters
- - `field` - Atom representing the member field name (e.g., `:email`, `:street`)
-
- ## Returns
- - `true` if the field should be shown in overview (default)
- - `false` if the field is configured as hidden in settings
-
- ## Examples
-
- iex> Member.show_in_overview?(:email)
- true
-
- iex> Member.show_in_overview?(:street)
- true # or false if configured in settings
-
- """
- @spec show_in_overview?(atom()) :: boolean()
- def show_in_overview?(field) when is_atom(field) do
- case Mv.Membership.get_settings() do
- {:ok, settings} ->
- visibility_config = settings.member_field_visibility || %{}
- # Normalize map keys to atoms (JSONB may return string keys)
- normalized_config = normalize_visibility_config(visibility_config)
-
- # Get value from normalized config, default to true
- Map.get(normalized_config, field, true)
-
- {:error, _} ->
- # If settings can't be loaded, default to visible
- true
- end
- end
-
- def show_in_overview?(_), do: true
-
- # Normalizes visibility config map keys from strings to atoms.
- # JSONB in PostgreSQL converts atom keys to string keys when storing.
- defp normalize_visibility_config(config) when is_map(config) do
- Enum.reduce(config, %{}, fn
- {key, value}, acc when is_atom(key) ->
- Map.put(acc, key, value)
-
- {key, value}, acc when is_binary(key) ->
- try do
- atom_key = String.to_existing_atom(key)
- Map.put(acc, atom_key, value)
- rescue
- ArgumentError ->
- acc
- end
-
- _, acc ->
- acc
- end)
- end
-
- defp normalize_visibility_config(_), do: %{}
-
@doc """
Performs fuzzy search on members using PostgreSQL trigram similarity.
diff --git a/lib/mv/constants.ex b/lib/mv/constants.ex
index 7bfb07b..334bcc1 100644
--- a/lib/mv/constants.ex
+++ b/lib/mv/constants.ex
@@ -18,17 +18,5 @@ defmodule Mv.Constants do
:postal_code
]
- @custom_field_prefix "custom_field_"
-
def member_fields, do: @member_fields
-
- @doc """
- Returns the prefix used for custom field keys in field visibility maps.
-
- ## Examples
-
- iex> Mv.Constants.custom_field_prefix()
- "custom_field_"
- """
- def custom_field_prefix, do: @custom_field_prefix
end
diff --git a/lib/mv_web/components/core_components.ex b/lib/mv_web/components/core_components.ex
index be64655..08133b5 100644
--- a/lib/mv_web/components/core_components.ex
+++ b/lib/mv_web/components/core_components.ex
@@ -119,123 +119,6 @@ defmodule MvWeb.CoreComponents do
end
end
- @doc """
- Renders a dropdown menu.
-
- ## Examples
-
- <.dropdown_menu items={@items} open={@open} phx_target={@myself} />
- """
- attr :id, :string, default: "dropdown-menu"
- attr :items, :list, required: true, doc: "List of %{label: string, value: any} maps"
- attr :button_label, :string, default: "Dropdown"
- attr :icon, :string, default: nil
- attr :checkboxes, :boolean, default: false
- attr :selected, :map, default: %{}
- attr :open, :boolean, default: false, doc: "Whether the dropdown is open"
- attr :show_select_buttons, :boolean, default: false, doc: "Show select all/none buttons"
- attr :phx_target, :any, required: true, doc: "The LiveView/LiveComponent target for events"
-
- def dropdown_menu(assigns) do
- ~H"""
-
-
-
-
- -
-
-
{gettext("Options")}
-
-
-
-
-
-
-
-
-
- <%= for item <- @items do %>
- -
-
-
- <% end %>
-
-
- """
- end
-
@doc """
Renders an input with label and error messages.
diff --git a/lib/mv_web/live/components/field_visibility_dropdown_component.ex b/lib/mv_web/live/components/field_visibility_dropdown_component.ex
deleted file mode 100644
index 642273c..0000000
--- a/lib/mv_web/live/components/field_visibility_dropdown_component.ex
+++ /dev/null
@@ -1,176 +0,0 @@
-defmodule MvWeb.Components.FieldVisibilityDropdownComponent do
- @moduledoc """
- LiveComponent for managing field visibility in the member overview.
-
- Provides an accessible dropdown menu where users can select/deselect
- which member fields and custom fields are visible in the table.
-
- ## Props
- - `:all_fields` - List of all available fields
- - `:custom_fields` - List of CustomField resources
- - `:selected_fields` - Map field_name → boolean
- - `:id` - Component ID
-
- ## Events sent to parent:
- - `{:field_toggled, field, value}`
- - `{:fields_selected, map}`
- """
-
- use MvWeb, :live_component
-
- # ---------------------------------------------------------------------------
- # UPDATE
- # ---------------------------------------------------------------------------
-
- @impl true
- def update(assigns, socket) do
- socket =
- socket
- |> assign(assigns)
- |> assign_new(:open, fn -> false end)
- |> assign_new(:all_fields, fn -> [] end)
- |> assign_new(:custom_fields, fn -> [] end)
- |> assign_new(:selected_fields, fn -> %{} end)
-
- {:ok, socket}
- end
-
- # ---------------------------------------------------------------------------
- # RENDER
- # ---------------------------------------------------------------------------
-
- @impl true
- def render(assigns) do
- all_fields = assigns.all_fields || []
- custom_fields = assigns.custom_fields || []
-
- all_items =
- Enum.map(extract_member_field_keys(all_fields), fn field ->
- %{
- value: field_to_string(field),
- label: format_field_label(field)
- }
- end) ++
- Enum.map(extract_custom_field_keys(all_fields), fn field ->
- %{
- value: field,
- label: format_custom_field_label(field, custom_fields)
- }
- end)
-
- assigns = assign(assigns, :all_items, all_items)
-
- # LiveComponents require a static HTML element as root, not a function component
- ~H"""
-
- <.dropdown_menu
- id="field-visibility-menu"
- icon="hero-adjustments-horizontal"
- button_label={gettext("Columns")}
- items={@all_items}
- checkboxes={true}
- selected={@selected_fields}
- open={@open}
- show_select_buttons={true}
- phx_target={@myself}
- />
-
- """
- end
-
- # ---------------------------------------------------------------------------
- # EVENTS (matching the Core Component API)
- # ---------------------------------------------------------------------------
-
- @impl true
- def handle_event("toggle_dropdown", _params, socket) do
- {:noreply, assign(socket, :open, !socket.assigns.open)}
- end
-
- def handle_event("close_dropdown", _params, socket) do
- {:noreply, assign(socket, :open, false)}
- end
-
- # toggle single item
- def handle_event("select_item", %{"item" => item}, socket) do
- current = Map.get(socket.assigns.selected_fields, item, true)
- updated = Map.put(socket.assigns.selected_fields, item, !current)
-
- send(self(), {:field_toggled, item, !current})
- {:noreply, assign(socket, :selected_fields, updated)}
- end
-
- # select all
- def handle_event("select_all", _params, socket) do
- all =
- socket.assigns.all_fields
- |> Enum.map(&field_to_string/1)
- |> Enum.map(&{&1, true})
- |> Enum.into(%{})
-
- send(self(), {:fields_selected, all})
- {:noreply, assign(socket, :selected_fields, all)}
- end
-
- # select none
- def handle_event("select_none", _params, socket) do
- none =
- socket.assigns.all_fields
- |> Enum.map(&field_to_string/1)
- |> Enum.map(&{&1, false})
- |> Enum.into(%{})
-
- send(self(), {:fields_selected, none})
- {:noreply, assign(socket, :selected_fields, none)}
- end
-
- # ---------------------------------------------------------------------------
- # HELPERS (with defensive nil guards)
- # ---------------------------------------------------------------------------
-
- defp extract_member_field_keys(nil), do: []
-
- defp extract_member_field_keys(fields) do
- prefix = Mv.Constants.custom_field_prefix()
-
- Enum.filter(fields, fn field ->
- is_atom(field) ||
- (is_binary(field) && not String.starts_with?(field, prefix))
- end)
- end
-
- defp extract_custom_field_keys(nil), do: []
-
- defp extract_custom_field_keys(fields) do
- prefix = Mv.Constants.custom_field_prefix()
-
- Enum.filter(fields, fn field ->
- is_binary(field) && String.starts_with?(field, prefix)
- end)
- end
-
- defp field_to_string(field) when is_atom(field), do: Atom.to_string(field)
- defp field_to_string(field) when is_binary(field), do: field
-
- defp format_field_label(field) do
- field
- |> field_to_string()
- |> String.replace("_", " ")
- |> String.split()
- |> Enum.map_join(" ", &String.capitalize/1)
- end
-
- defp format_custom_field_label(field_string, custom_fields) do
- id = String.trim_leading(field_string, Mv.Constants.custom_field_prefix())
- find_custom_field_name(id, field_string, custom_fields)
- end
-
- defp find_custom_field_name("", field_string, _custom_fields), do: field_string
-
- defp find_custom_field_name(id, _field_string, custom_fields) do
- case Enum.find(custom_fields, fn cf -> to_string(cf.id) == id end) do
- nil -> gettext("Custom Field %{id}", id: id)
- custom_field -> custom_field.name
- end
- end
-end
diff --git a/lib/mv_web/live/member_live/index.ex b/lib/mv_web/live/member_live/index.ex
index ad4a4a9..792466c 100644
--- a/lib/mv_web/live/member_live/index.ex
+++ b/lib/mv_web/live/member_live/index.ex
@@ -33,11 +33,9 @@ defmodule MvWeb.MemberLive.Index do
alias Mv.Membership
alias MvWeb.MemberLive.Index.Formatter
alias MvWeb.Helpers.DateFormatter
- alias MvWeb.MemberLive.Index.FieldSelection
- alias MvWeb.MemberLive.Index.FieldVisibility
# Prefix used in sort field names for custom fields (e.g., "custom_field_")
- @custom_field_prefix Mv.Constants.custom_field_prefix()
+ @custom_field_prefix "custom_field_"
# Member fields that are loaded for the overview
# Uses constants from Mv.Constants to ensure consistency
@@ -52,8 +50,8 @@ defmodule MvWeb.MemberLive.Index do
payment filter, and member selection. Actual data loading happens in `handle_params/3`.
"""
@impl true
- def mount(_params, session, socket) do
- # Load custom fields that should be shown in overview (for display)
+ 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.
@@ -63,12 +61,6 @@ defmodule MvWeb.MemberLive.Index do
|> Ash.Query.sort(name: :asc)
|> Ash.read!()
- # Load ALL custom fields for the dropdown (to show all available fields)
- all_custom_fields =
- Mv.Membership.CustomField
- |> Ash.Query.sort(name: :asc)
- |> Ash.read!()
-
# Load settings once to avoid N+1 queries
settings =
case Membership.get_settings() do
@@ -77,20 +69,6 @@ defmodule MvWeb.MemberLive.Index do
{:error, _} -> %{member_field_visibility: %{}}
end
- # Load user field selection from session
- session_selection = FieldSelection.get_from_session(session)
-
- # Get all available fields (for dropdown - includes ALL custom fields)
- all_available_fields = FieldVisibility.get_all_available_fields(all_custom_fields)
-
- # Merge session selection with global settings for initial state (use all_custom_fields)
- initial_selection =
- FieldVisibility.merge_with_global_settings(
- session_selection,
- settings,
- all_custom_fields
- )
-
socket =
socket
|> assign(:page_title, gettext("Members"))
@@ -99,15 +77,8 @@ defmodule MvWeb.MemberLive.Index do
|> assign_new(:sort_order, fn -> :asc end)
|> assign(:paid_filter, nil)
|> assign(:selected_members, MapSet.new())
- |> assign(:settings, settings)
|> assign(:custom_fields_visible, custom_fields_visible)
- |> assign(:all_custom_fields, all_custom_fields)
- |> assign(:all_available_fields, all_available_fields)
- |> assign(:user_field_selection, initial_selection)
- |> assign(
- :member_fields_visible,
- FieldVisibility.get_visible_member_fields(initial_selection)
- )
+ |> assign(:member_fields_visible, get_visible_member_fields(settings))
# We call handle params to use the query from the URL
{:ok, socket}
@@ -212,8 +183,6 @@ defmodule MvWeb.MemberLive.Index do
## Supported messages:
- `{:sort, field}` - Sort event from SortHeaderComponent. Updates sort field/order and syncs URL
- `{:search_changed, query}` - Search event from SearchBarComponent. Filters members and syncs URL
- - `{:field_toggled, field, visible}` - Field toggle event from FieldVisibilityDropdownComponent
- - `{:fields_selected, selection}` - Select all/deselect all event from FieldVisibilityDropdownComponent
"""
@impl true
def handle_info({:sort, field_str}, socket) do
@@ -282,111 +251,24 @@ defmodule MvWeb.MemberLive.Index do
)}
end
- @impl true
- def handle_info({:field_toggled, field_string, visible}, socket) do
- # Update user field selection
- new_selection = Map.put(socket.assigns.user_field_selection, field_string, visible)
-
- # Save to session (cookie will be saved on next page load via handle_params)
- socket = update_session_field_selection(socket, new_selection)
-
- # Merge with global settings (use all_custom_fields to allow enabling globally hidden fields)
- final_selection =
- FieldVisibility.merge_with_global_settings(
- new_selection,
- socket.assigns.settings,
- socket.assigns.all_custom_fields
- )
-
- # Get visible fields
- visible_member_fields = FieldVisibility.get_visible_member_fields(final_selection)
- visible_custom_fields = FieldVisibility.get_visible_custom_fields(final_selection)
-
- socket =
- socket
- |> assign(:user_field_selection, final_selection)
- |> assign(:member_fields_visible, visible_member_fields)
- |> assign(:visible_custom_field_ids, extract_custom_field_ids(visible_custom_fields))
- |> load_members()
- |> prepare_dynamic_cols()
- |> push_field_selection_url()
-
- {:noreply, socket}
- end
-
- @impl true
- def handle_info({:fields_selected, selection}, socket) do
- # Save to session
- socket = update_session_field_selection(socket, selection)
-
- # Merge with global settings (use all_custom_fields for merging)
- final_selection =
- FieldVisibility.merge_with_global_settings(
- selection,
- socket.assigns.settings,
- socket.assigns.all_custom_fields
- )
-
- # Get visible fields
- visible_member_fields = FieldVisibility.get_visible_member_fields(final_selection)
- visible_custom_fields = FieldVisibility.get_visible_custom_fields(final_selection)
-
- socket =
- socket
- |> assign(:user_field_selection, final_selection)
- |> assign(:member_fields_visible, visible_member_fields)
- |> assign(:visible_custom_field_ids, extract_custom_field_ids(visible_custom_fields))
- |> load_members()
- |> prepare_dynamic_cols()
- |> push_field_selection_url()
-
- {:noreply, socket}
- end
-
# -----------------------------------------------------------------
# Handle Params from the URL
# -----------------------------------------------------------------
@doc """
Handles URL parameter changes.
- Parses query parameters for search query, sort field, sort order, and payment filter, and field selection,
+ Parses query parameters for search query, sort field, sort order, and payment filter,
then loads members accordingly. This enables bookmarkable URLs and
browser back/forward navigation.
"""
@impl true
def handle_params(params, _url, socket) do
- # Parse field selection from URL
- url_selection = FieldSelection.parse_from_url(params)
-
- # Merge with session selection (URL has priority)
- merged_selection =
- FieldSelection.merge_sources(
- url_selection,
- socket.assigns.user_field_selection,
- %{}
- )
-
- # Merge with global settings (use all_custom_fields for merging)
- final_selection =
- FieldVisibility.merge_with_global_settings(
- merged_selection,
- socket.assigns.settings,
- socket.assigns.all_custom_fields
- )
-
- # Get visible fields
- visible_member_fields = FieldVisibility.get_visible_member_fields(final_selection)
- visible_custom_fields = FieldVisibility.get_visible_custom_fields(final_selection)
-
socket =
socket
|> maybe_update_search(params)
|> maybe_update_sort(params)
|> maybe_update_paid_filter(params)
|> assign(:query, params["query"])
- |> assign(:user_field_selection, final_selection)
- |> assign(:member_fields_visible, visible_member_fields)
- |> assign(:visible_custom_field_ids, extract_custom_field_ids(visible_custom_fields))
|> load_members()
|> prepare_dynamic_cols()
@@ -399,17 +281,10 @@ defmodule MvWeb.MemberLive.Index do
# - `:custom_field` - The CustomField resource
# - `:render` - A function that formats the custom field value for a given member
#
- # Only includes custom fields that are visible according to user field selection.
- #
# Returns the socket with `:dynamic_cols` assigned.
defp prepare_dynamic_cols(socket) do
- visible_custom_field_ids = socket.assigns[:visible_custom_field_ids] || []
-
- # Use all_custom_fields to allow users to enable globally hidden custom fields
dynamic_cols =
- socket.assigns.all_custom_fields
- |> Enum.filter(fn custom_field -> custom_field.id in visible_custom_field_ids end)
- |> Enum.map(fn custom_field ->
+ Enum.map(socket.assigns.custom_fields_visible, fn custom_field ->
%{
custom_field: custom_field,
render: fn member ->
@@ -502,58 +377,6 @@ defmodule MvWeb.MemberLive.Index do
)}
end
- # Builds query parameters including field selection
- defp build_query_params(socket, base_params) do
- # Use query from base_params if provided, otherwise fall back to socket.assigns.query
- query_value = Map.get(base_params, "query") || socket.assigns.query || ""
-
- base_params
- |> Map.put("query", query_value)
- |> maybe_add_field_selection(socket.assigns[:user_field_selection])
- end
-
- # Adds field selection to query params if present
- defp maybe_add_field_selection(params, nil), do: params
-
- defp maybe_add_field_selection(params, selection) when is_map(selection) do
- fields_param = FieldSelection.to_url_param(selection)
- if fields_param != "", do: Map.put(params, "fields", fields_param), else: params
- end
-
- defp maybe_add_field_selection(params, _), do: params
-
- # Pushes URL with updated field selection
- defp push_field_selection_url(socket) do
- base_params = %{
- "sort_field" => field_to_string(socket.assigns.sort_field),
- "sort_order" => Atom.to_string(socket.assigns.sort_order)
- }
-
- # Include paid_filter if set
- base_params =
- case socket.assigns.paid_filter do
- nil -> base_params
- :paid -> Map.put(base_params, "paid_filter", "paid")
- :not_paid -> Map.put(base_params, "paid_filter", "not_paid")
- end
-
- query_params = build_query_params(socket, base_params)
- new_path = ~p"/members?#{query_params}"
-
- push_patch(socket, to: new_path, replace: true)
- end
-
- # Converts field to string
- defp field_to_string(field) when is_atom(field), do: Atom.to_string(field)
- defp field_to_string(field) when is_binary(field), do: field
-
- # Updates session field selection (stored in socket for now, actual session update via controller)
- defp update_session_field_selection(socket, selection) do
- # Store in socket for now - actual session persistence would require a controller
- # This is a placeholder for future session persistence
- assign(socket, :user_field_selection, selection)
- end
-
# Builds URL query parameters map including all filter/sort state.
# Converts paid_filter atom to string for URL.
defp build_query_params(query, sort_field, sort_order, paid_filter) do
@@ -612,9 +435,9 @@ defmodule MvWeb.MemberLive.Index do
|> Ash.Query.new()
|> Ash.Query.select(@overview_fields)
- # Load custom field values for visible custom fields (based on user selection)
- visible_custom_field_ids = socket.assigns[:visible_custom_field_ids] || []
- query = load_custom_field_values(query, visible_custom_field_ids)
+ # 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)
@@ -792,18 +615,6 @@ defmodule MvWeb.MemberLive.Index do
defp extract_custom_field_id(_), do: nil
- # Extracts custom field IDs from visible custom field strings
- # Format: "custom_field_" ->
- defp extract_custom_field_ids(visible_custom_fields) do
- Enum.map(visible_custom_fields, fn field_string ->
- case String.split(field_string, @custom_field_prefix) do
- ["", id] -> id
- _ -> nil
- end
- end)
- |> Enum.filter(&(&1 != nil))
- end
-
# Sorts members in memory by a custom field value.
#
# Process:
@@ -1100,6 +911,34 @@ defmodule MvWeb.MemberLive.Index do
end
end
+ # Gets the list of member fields that should be visible in the overview.
+ #
+ # Reads the visibility configuration from Settings and returns only the fields
+ # where show_in_overview is true. Fields not configured in settings default to true.
+ #
+ # Performance: This function uses the already-loaded settings to avoid N+1 queries.
+ # Settings should be loaded once in mount/3 and passed to this function.
+ #
+ # Parameters:
+ # - `settings` - The settings struct loaded from the database
+ #
+ # Returns a list of atoms representing visible member field names.
+ #
+ # Fields are read from the global Constants module.
+ @spec get_visible_member_fields(map()) :: [atom()]
+ defp get_visible_member_fields(settings) do
+ # Get all eligible fields from the global constants
+ all_fields = Mv.Constants.member_fields()
+
+ # JSONB stores keys as strings
+ visibility_config = settings.member_field_visibility || %{}
+
+ # Filter to only return visible fields
+ Enum.filter(all_fields, fn field ->
+ Map.get(visibility_config, Atom.to_string(field), true)
+ end)
+ end
+
# Public helper function to format dates for use in templates
def format_date(date), do: DateFormatter.format_date(date)
end
diff --git a/lib/mv_web/live/member_live/index.html.heex b/lib/mv_web/live/member_live/index.html.heex
index 1658209..959a3bc 100644
--- a/lib/mv_web/live/member_live/index.html.heex
+++ b/lib/mv_web/live/member_live/index.html.heex
@@ -44,13 +44,6 @@
paid_filter={@paid_filter}
member_count={length(@members)}
/>
- <.live_component
- module={MvWeb.Components.FieldVisibilityDropdownComponent}
- id="field-visibility-dropdown"
- all_fields={@all_available_fields}
- custom_fields={@all_custom_fields}
- selected_fields={@user_field_selection}
- />
<.table
@@ -92,7 +85,6 @@
<:col
:let={member}
- :if={:first_name in @member_fields_visible}
label={
~H"""
<.live_component
@@ -106,25 +98,7 @@
"""
}
>
- {member.first_name}
-
- <:col
- :let={member}
- :if={:last_name in @member_fields_visible}
- label={
- ~H"""
- <.live_component
- module={MvWeb.Components.SortHeaderComponent}
- id={:sort_last_name}
- field={:last_name}
- label={gettext("Last name")}
- sort_field={@sort_field}
- sort_order={@sort_order}
- />
- """
- }
- >
- {member.last_name}
+ {member.first_name} {member.last_name}
<:col
:let={member}
@@ -252,7 +226,7 @@
>
{MvWeb.MemberLive.Index.format_date(member.join_date)}
- <:col :let={member} :if={:paid in @member_fields_visible} label={gettext("Paid")}>
+ <:col :let={member} label={gettext("Paid")}>
Session > Cookie
-
- ## Data Format
-
- Field selection is stored as a map:
- ```elixir
- %{
- "first_name" => true,
- "email" => true,
- "street" => false,
- "custom_field_abc-123" => true
- }
- ```
-
- ## Cookie/Session Format
-
- Stored as JSON string: `{"first_name":true,"email":true}`
-
- ## URL Format
-
- Comma-separated list: `?fields=first_name,email,custom_field_abc-123`
- """
-
- @cookie_name "member_field_selection"
- @cookie_max_age 365 * 24 * 60 * 60
- @session_key "member_field_selection"
-
- @doc """
- Reads field selection from session.
-
- Returns a map of field names (strings) to boolean visibility values.
- Returns empty map if no selection is stored.
- """
- @spec get_from_session(map()) :: %{String.t() => boolean()}
- def get_from_session(session) when is_map(session) do
- case Map.get(session, @session_key) do
- nil -> %{}
- json_string when is_binary(json_string) -> parse_json(json_string)
- _ -> %{}
- end
- end
-
- def get_from_session(_), do: %{}
-
- @doc """
- Saves field selection to session.
-
- Converts the map to JSON string and stores it in the session.
- """
- @spec save_to_session(map(), %{String.t() => boolean()}) :: map()
- def save_to_session(session, selection) when is_map(selection) do
- json_string = Jason.encode!(selection)
- Map.put(session, @session_key, json_string)
- end
-
- def save_to_session(session, _), do: session
-
- @doc """
- Reads field selection from cookie.
-
- Returns a map of field names (strings) to boolean visibility values.
- Returns empty map if no cookie is present.
-
- Note: This function parses the raw Cookie header. In LiveView, cookies
- are typically accessed via get_connect_info.
- """
- @spec get_from_cookie(Plug.Conn.t()) :: %{String.t() => boolean()}
- def get_from_cookie(conn) do
- # get_req_header always returns a list ([] if no header, [value] if present)
- case Plug.Conn.get_req_header(conn, "cookie") do
- [] ->
- %{}
-
- [cookie_header | _rest] ->
- cookies = parse_cookie_header(cookie_header)
-
- case Map.get(cookies, @cookie_name) do
- nil -> %{}
- json_string when is_binary(json_string) -> parse_json(json_string)
- _ -> %{}
- end
- end
- end
-
- # Parses cookie header string into a map
- defp parse_cookie_header(cookie_header) when is_binary(cookie_header) do
- cookie_header
- |> String.split(";")
- |> Enum.map(&String.trim/1)
- |> Enum.map(&String.split(&1, "=", parts: 2))
- |> Enum.reduce(%{}, fn
- [key, value], acc -> Map.put(acc, key, URI.decode(value))
- [key], acc -> Map.put(acc, key, "")
- _, acc -> acc
- end)
- end
-
- defp parse_cookie_header(_), do: %{}
-
- @doc """
- Saves field selection to cookie.
-
- Sets a persistent cookie with the field selection as JSON.
- """
- @spec save_to_cookie(Plug.Conn.t(), %{String.t() => boolean()}) :: Plug.Conn.t()
- def save_to_cookie(conn, selection) when is_map(selection) do
- json_string = Jason.encode!(selection)
- secure = Application.get_env(:mv, :use_secure_cookies, false)
-
- Plug.Conn.put_resp_cookie(conn, @cookie_name, json_string,
- max_age: @cookie_max_age,
- same_site: "Lax",
- http_only: true,
- secure: secure
- )
- end
-
- def save_to_cookie(conn, _), do: conn
-
- @doc """
- Parses field selection from URL parameters.
-
- Expects a comma-separated list of field names in the `fields` parameter.
- All fields in the list are set to `true` (visible).
-
- ## Examples
-
- iex> parse_from_url(%{"fields" => "first_name,email"})
- %{"first_name" => true, "email" => true}
-
- iex> parse_from_url(%{"fields" => "custom_field_abc-123"})
- %{"custom_field_abc-123" => true}
-
- iex> parse_from_url(%{})
- %{}
- """
- @spec parse_from_url(map()) :: %{String.t() => boolean()}
- def parse_from_url(params) when is_map(params) do
- case Map.get(params, "fields") do
- nil -> %{}
- "" -> %{}
- fields_string when is_binary(fields_string) -> parse_fields_string(fields_string)
- _ -> %{}
- end
- end
-
- def parse_from_url(_), do: %{}
-
- @doc """
- Merges multiple field selection sources with priority.
-
- Priority order (highest to lowest):
- 1. URL parameters
- 2. Session
- 3. Cookie
-
- Later sources override earlier ones for the same field.
-
- ## Examples
-
- iex> merge_sources(%{"first_name" => true}, %{"email" => true}, %{"street" => true})
- %{"first_name" => true, "email" => true, "street" => true}
-
- iex> merge_sources(%{"first_name" => false}, %{"first_name" => true}, %{})
- %{"first_name" => false} # URL has priority
- """
- @spec merge_sources(
- %{String.t() => boolean()},
- %{String.t() => boolean()},
- %{String.t() => boolean()}
- ) :: %{String.t() => boolean()}
- def merge_sources(url_selection, session_selection, cookie_selection) do
- %{}
- |> Map.merge(cookie_selection)
- |> Map.merge(session_selection)
- |> Map.merge(url_selection)
- end
-
- @doc """
- Converts field selection map to URL parameter string.
-
- Returns a comma-separated string of visible fields (where value is `true`).
-
- ## Examples
-
- iex> to_url_param(%{"first_name" => true, "email" => true, "street" => false})
- "first_name,email"
- """
- @spec to_url_param(%{String.t() => boolean()}) :: String.t()
- def to_url_param(selection) when is_map(selection) do
- selection
- |> Enum.filter(fn {_field, visible} -> visible end)
- |> Enum.map_join(",", fn {field, _visible} -> field end)
- end
-
- def to_url_param(_), do: ""
-
- # Parses a JSON string into a map, handling errors gracefully
- defp parse_json(json_string) when is_binary(json_string) do
- case Jason.decode(json_string) do
- {:ok, decoded} when is_map(decoded) ->
- # Ensure all values are booleans
- Enum.reduce(decoded, %{}, fn
- {key, value}, acc when is_boolean(value) -> Map.put(acc, key, value)
- {key, _value}, acc -> Map.put(acc, key, true)
- end)
-
- _ ->
- %{}
- end
- end
-
- defp parse_json(_), do: %{}
-
- # Parses a comma-separated string of field names
- defp parse_fields_string(fields_string) do
- fields_string
- |> String.split(",")
- |> Enum.map(&String.trim/1)
- |> Enum.filter(&(&1 != ""))
- |> Enum.reduce(%{}, fn field, acc -> Map.put(acc, field, true) end)
- end
-end
diff --git a/lib/mv_web/live/member_live/index/field_visibility.ex b/lib/mv_web/live/member_live/index/field_visibility.ex
deleted file mode 100644
index c9c8bd6..0000000
--- a/lib/mv_web/live/member_live/index/field_visibility.ex
+++ /dev/null
@@ -1,239 +0,0 @@
-defmodule MvWeb.MemberLive.Index.FieldVisibility do
- @moduledoc """
- Manages field visibility by merging user-specific selection with global settings.
-
- This module handles:
- - Getting all available fields (member fields + custom fields)
- - Merging user selection with global settings (user selection takes priority)
- - Falling back to global settings when no user selection exists
- - Converting between different field name formats (atoms vs strings)
-
- ## Field Naming Convention
-
- - **Member Fields**: Atoms (e.g., `:first_name`, `:email`)
- - **Custom Fields**: Strings with format `"custom_field_"` (e.g., `"custom_field_abc-123"`)
-
- ## Priority Order
-
- 1. User-specific selection (from URL/Session/Cookie)
- 2. Global settings (from database)
- 3. Default (all fields visible)
- """
-
- @doc """
- Gets all available fields for selection.
-
- Returns a list of field identifiers:
- - Member fields as atoms (e.g., `:first_name`, `:email`)
- - Custom fields as strings (e.g., `"custom_field_abc-123"`)
-
- ## Parameters
-
- - `custom_fields` - List of CustomField resources that are available
-
- ## Returns
-
- List of field identifiers (atoms and strings)
- """
- @spec get_all_available_fields([struct()]) :: [atom() | String.t()]
- def get_all_available_fields(custom_fields) do
- member_fields = Mv.Constants.member_fields()
- custom_field_names = Enum.map(custom_fields, &"custom_field_#{&1.id}")
-
- member_fields ++ custom_field_names
- end
-
- @doc """
- Merges user field selection with global settings.
-
- User selection takes priority over global settings. If a field is not in the
- user selection, the global setting is used. If a field is not in global settings,
- it defaults to `true` (visible).
-
- ## Parameters
-
- - `user_selection` - Map of field names (strings) to boolean visibility
- - `global_settings` - Settings struct with `member_field_visibility` field
- - `custom_fields` - List of CustomField resources
-
- ## Returns
-
- Map of field names (strings) to boolean visibility values
-
- ## Examples
-
- iex> user_selection = %{"first_name" => false}
- iex> settings = %{member_field_visibility: %{first_name: true, email: true}}
- iex> merge_with_global_settings(user_selection, settings, [])
- %{"first_name" => false, "email" => true} # User selection overrides global
- """
- @spec merge_with_global_settings(
- %{String.t() => boolean()},
- map(),
- [struct()]
- ) :: %{String.t() => boolean()}
- def merge_with_global_settings(user_selection, global_settings, custom_fields) do
- all_fields = get_all_available_fields(custom_fields)
- global_visibility = get_global_visibility_map(global_settings, custom_fields)
-
- Enum.reduce(all_fields, %{}, fn field, acc ->
- field_string = field_to_string(field)
-
- visibility =
- case Map.get(user_selection, field_string) do
- nil -> Map.get(global_visibility, field_string, true)
- user_value -> user_value
- end
-
- Map.put(acc, field_string, visibility)
- end)
- end
-
- @doc """
- Gets the list of visible fields from a field selection map.
-
- Returns only fields where visibility is `true`.
-
- ## Parameters
-
- - `field_selection` - Map of field names to boolean visibility
-
- ## Returns
-
- List of field identifiers (atoms for member fields, strings for custom fields)
-
- ## Examples
-
- iex> selection = %{"first_name" => true, "email" => false, "street" => true}
- iex> get_visible_fields(selection)
- [:first_name, :street]
- """
- @spec get_visible_fields(%{String.t() => boolean()}) :: [atom() | String.t()]
- def get_visible_fields(field_selection) when is_map(field_selection) do
- field_selection
- |> Enum.filter(fn {_field, visible} -> visible end)
- |> Enum.map(fn {field_string, _visible} -> to_field_identifier(field_string) end)
- end
-
- def get_visible_fields(_), do: []
-
- @doc """
- Gets visible member fields from field selection.
-
- Returns only member fields (atoms) that are visible.
-
- ## Examples
-
- iex> selection = %{"first_name" => true, "email" => true, "custom_field_123" => true}
- iex> get_visible_member_fields(selection)
- [:first_name, :email]
- """
- @spec get_visible_member_fields(%{String.t() => boolean()}) :: [atom()]
- def get_visible_member_fields(field_selection) when is_map(field_selection) do
- member_fields = Mv.Constants.member_fields()
-
- field_selection
- |> Enum.filter(fn {field_string, visible} ->
- field_atom = to_field_identifier(field_string)
- visible && field_atom in member_fields
- end)
- |> Enum.map(fn {field_string, _visible} -> to_field_identifier(field_string) end)
- end
-
- def get_visible_member_fields(_), do: []
-
- @doc """
- Gets visible custom fields from field selection.
-
- Returns only custom field identifiers (strings) that are visible.
-
- ## Examples
-
- iex> selection = %{"first_name" => true, "custom_field_123" => true, "custom_field_456" => false}
- iex> get_visible_custom_fields(selection)
- ["custom_field_123"]
- """
- @spec get_visible_custom_fields(%{String.t() => boolean()}) :: [String.t()]
- def get_visible_custom_fields(field_selection) when is_map(field_selection) do
- prefix = Mv.Constants.custom_field_prefix()
-
- field_selection
- |> Enum.filter(fn {field_string, visible} ->
- visible && String.starts_with?(field_string, prefix)
- end)
- |> Enum.map(fn {field_string, _visible} -> field_string end)
- end
-
- def get_visible_custom_fields(_), do: []
-
- # Gets global visibility map from settings
- defp get_global_visibility_map(settings, custom_fields) do
- member_visibility = get_member_field_visibility_from_settings(settings)
- custom_field_visibility = get_custom_field_visibility(custom_fields)
-
- Map.merge(member_visibility, custom_field_visibility)
- end
-
- # Gets member field visibility from settings
- defp get_member_field_visibility_from_settings(settings) do
- visibility_config =
- normalize_visibility_config(Map.get(settings, :member_field_visibility, %{}))
-
- member_fields = Mv.Constants.member_fields()
-
- Enum.reduce(member_fields, %{}, fn field, acc ->
- field_string = Atom.to_string(field)
- show_in_overview = Map.get(visibility_config, field, true)
- Map.put(acc, field_string, show_in_overview)
- end)
- end
-
- # Gets custom field visibility (all custom fields with show_in_overview=true are visible)
- defp get_custom_field_visibility(custom_fields) do
- prefix = Mv.Constants.custom_field_prefix()
-
- Enum.reduce(custom_fields, %{}, fn custom_field, acc ->
- field_string = "#{prefix}#{custom_field.id}"
- visible = Map.get(custom_field, :show_in_overview, true)
- Map.put(acc, field_string, visible)
- end)
- end
-
- # Normalizes visibility config map keys from strings to atoms
- defp normalize_visibility_config(config) when is_map(config) do
- Enum.reduce(config, %{}, fn
- {key, value}, acc when is_atom(key) ->
- Map.put(acc, key, value)
-
- {key, value}, acc when is_binary(key) ->
- try do
- atom_key = String.to_existing_atom(key)
- Map.put(acc, atom_key, value)
- rescue
- ArgumentError -> acc
- end
-
- _, acc ->
- acc
- end)
- end
-
- defp normalize_visibility_config(_), do: %{}
-
- # Converts field string to atom (for member fields) or keeps as string (for custom fields)
- defp to_field_identifier(field_string) when is_binary(field_string) do
- if String.starts_with?(field_string, Mv.Constants.custom_field_prefix()) do
- field_string
- else
- try do
- String.to_existing_atom(field_string)
- rescue
- ArgumentError -> field_string
- end
- end
- end
-
- # Converts field identifier to string
- defp field_to_string(field) when is_atom(field), do: Atom.to_string(field)
- defp field_to_string(field) when is_binary(field), do: field
-end
diff --git a/priv/gettext/de/LC_MESSAGES/default.po b/priv/gettext/de/LC_MESSAGES/default.po
index bb781f7..3f80877 100644
--- a/priv/gettext/de/LC_MESSAGES/default.po
+++ b/priv/gettext/de/LC_MESSAGES/default.po
@@ -783,7 +783,6 @@ msgstr "Tipp: E-Mail-Adressen ins BCC-Feld einfügen für Datenschutzkonformitä
msgid "This field cannot be empty"
msgstr "Dieses Feld darf nicht leer bleiben"
-#: lib/mv_web/components/core_components.ex
#: lib/mv_web/live/components/payment_filter_component.ex
#, elixir-autogen, elixir-format
msgid "All"
@@ -1320,41 +1319,6 @@ msgstr ""
msgid "Yearly Interval - Joining Period Included"
msgstr ""
-#: lib/mv_web/live/components/field_visibility_dropdown_component.ex
-#, elixir-autogen, elixir-format
-msgid "Columns"
-msgstr "Spalten"
-
-#: lib/mv_web/live/components/field_visibility_dropdown_component.ex
-#, elixir-autogen, elixir-format
-msgid "Custom Field %{id}"
-msgstr "Benutzerdefiniertes Feld %{id}"
-
-#: lib/mv_web/live/member_live/index.html.heex
-#, elixir-autogen, elixir-format
-msgid "Last name"
-msgstr "Nachname"
-
-#: lib/mv_web/components/core_components.ex
-#, elixir-autogen, elixir-format
-msgid "None"
-msgstr "Keine"
-
-#: lib/mv_web/components/core_components.ex
-#, elixir-autogen, elixir-format
-msgid "Options"
-msgstr "Optionen"
-
-#: lib/mv_web/components/core_components.ex
-#, elixir-autogen, elixir-format
-msgid "Select all"
-msgstr "Alle auswählen"
-
-#: lib/mv_web/components/core_components.ex
-#, elixir-autogen, elixir-format
-msgid "Select none"
-msgstr "Keine auswählen"
-
#: lib/mv_web/live/custom_field_live/form_component.ex
#, elixir-autogen, elixir-format
msgid "Back to custom field overview"
diff --git a/priv/gettext/default.pot b/priv/gettext/default.pot
index 7581d62..9a479c6 100644
--- a/priv/gettext/default.pot
+++ b/priv/gettext/default.pot
@@ -784,7 +784,6 @@ msgstr ""
msgid "This field cannot be empty"
msgstr ""
-#: lib/mv_web/components/core_components.ex
#: lib/mv_web/live/components/payment_filter_component.ex
#, elixir-autogen, elixir-format
msgid "All"
@@ -1321,41 +1320,6 @@ msgstr ""
msgid "Yearly Interval - Joining Period Included"
msgstr ""
-#: lib/mv_web/live/components/field_visibility_dropdown_component.ex
-#, elixir-autogen, elixir-format
-msgid "Columns"
-msgstr ""
-
-#: lib/mv_web/live/components/field_visibility_dropdown_component.ex
-#, elixir-autogen, elixir-format
-msgid "Custom Field %{id}"
-msgstr ""
-
-#: lib/mv_web/live/member_live/index.html.heex
-#, elixir-autogen, elixir-format
-msgid "Last name"
-msgstr ""
-
-#: lib/mv_web/components/core_components.ex
-#, elixir-autogen, elixir-format
-msgid "None"
-msgstr ""
-
-#: lib/mv_web/components/core_components.ex
-#, elixir-autogen, elixir-format
-msgid "Options"
-msgstr ""
-
-#: lib/mv_web/components/core_components.ex
-#, elixir-autogen, elixir-format
-msgid "Select all"
-msgstr ""
-
-#: lib/mv_web/components/core_components.ex
-#, elixir-autogen, elixir-format
-msgid "Select none"
-msgstr ""
-
#: lib/mv_web/live/custom_field_live/form_component.ex
#, elixir-autogen, elixir-format
msgid "Back to custom field overview"
diff --git a/priv/gettext/en/LC_MESSAGES/default.po b/priv/gettext/en/LC_MESSAGES/default.po
index dc86840..3d80e4d 100644
--- a/priv/gettext/en/LC_MESSAGES/default.po
+++ b/priv/gettext/en/LC_MESSAGES/default.po
@@ -784,7 +784,6 @@ msgstr ""
msgid "This field cannot be empty"
msgstr ""
-#: lib/mv_web/components/core_components.ex
#: lib/mv_web/live/components/payment_filter_component.ex
#, elixir-autogen, elixir-format
msgid "All"
@@ -1321,41 +1320,6 @@ msgstr ""
msgid "Yearly Interval - Joining Period Included"
msgstr ""
-#: lib/mv_web/live/components/field_visibility_dropdown_component.ex
-#, elixir-autogen, elixir-format
-msgid "Columns"
-msgstr ""
-
-#: lib/mv_web/live/components/field_visibility_dropdown_component.ex
-#, elixir-autogen, elixir-format, fuzzy
-msgid "Custom Field %{id}"
-msgstr ""
-
-#: lib/mv_web/live/member_live/index.html.heex
-#, elixir-autogen, elixir-format, fuzzy
-msgid "Last name"
-msgstr ""
-
-#: lib/mv_web/components/core_components.ex
-#, elixir-autogen, elixir-format, fuzzy
-msgid "None"
-msgstr ""
-
-#: lib/mv_web/components/core_components.ex
-#, elixir-autogen, elixir-format, fuzzy
-msgid "Options"
-msgstr ""
-
-#: lib/mv_web/components/core_components.ex
-#, elixir-autogen, elixir-format, fuzzy
-msgid "Select all"
-msgstr ""
-
-#: lib/mv_web/components/core_components.ex
-#, elixir-autogen, elixir-format, fuzzy
-msgid "Select none"
-msgstr ""
-
#: lib/mv_web/live/custom_field_live/form_component.ex
#, elixir-autogen, elixir-format
msgid "Back to custom field overview"
diff --git a/test/membership/member_field_visibility_test.exs b/test/membership/member_field_visibility_test.exs
deleted file mode 100644
index 9c7e5e0..0000000
--- a/test/membership/member_field_visibility_test.exs
+++ /dev/null
@@ -1,80 +0,0 @@
-defmodule Mv.Membership.MemberFieldVisibilityTest do
- @moduledoc """
- Tests for member field visibility configuration.
-
- Tests cover:
- - Member fields are visible by default (show_in_overview: true)
- - Member fields can be hidden (show_in_overview: false)
- - Checking if a specific field is visible
- - Configuration is stored in Settings resource
- """
- use Mv.DataCase, async: true
-
- alias Mv.Membership.Member
-
- describe "show_in_overview?/1" do
- test "returns true for all member fields by default" do
- # When no settings exist or member_field_visibility is not configured
- # Test with fields from constants
- member_fields = Mv.Constants.member_fields()
-
- Enum.each(member_fields, fn field ->
- assert Member.show_in_overview?(field) == true,
- "Field #{field} should be visible by default"
- end)
- end
-
- test "returns false for fields with show_in_overview: false in settings" do
- # Get or create settings
- {:ok, settings} = Mv.Membership.get_settings()
-
- # Use a field that exists in member fields
- member_fields = Mv.Constants.member_fields()
- field_to_hide = List.first(member_fields)
- field_to_show = List.last(member_fields)
-
- # Update settings to hide a field (use string keys for JSONB)
- {:ok, _updated_settings} =
- Mv.Membership.update_settings(settings, %{
- member_field_visibility: %{Atom.to_string(field_to_hide) => false}
- })
-
- # JSONB may convert atom keys to string keys, so we check via show_in_overview? instead
- assert Member.show_in_overview?(field_to_hide) == false
- assert Member.show_in_overview?(field_to_show) == true
- end
-
- test "returns true for non-configured fields (default)" do
- # Get or create settings
- {:ok, settings} = Mv.Membership.get_settings()
-
- # Use fields that exist in member fields
- member_fields = Mv.Constants.member_fields()
- fields_to_hide = Enum.take(member_fields, 2)
- fields_to_show = Enum.take(member_fields, -2)
-
- # Update settings to hide some fields (use string keys for JSONB)
- visibility_config =
- Enum.reduce(fields_to_hide, %{}, fn field, acc ->
- Map.put(acc, Atom.to_string(field), false)
- end)
-
- {:ok, _updated_settings} =
- Mv.Membership.update_settings(settings, %{
- member_field_visibility: visibility_config
- })
-
- # Hidden fields should be false
- Enum.each(fields_to_hide, fn field ->
- assert Member.show_in_overview?(field) == false,
- "Field #{field} should be hidden"
- end)
-
- # Unconfigured fields should still be true (default)
- Enum.each(fields_to_show, fn field ->
- assert Member.show_in_overview?(field) == true,
- "Field #{field} should be visible by default"
- end)
- end
- end
-end
diff --git a/test/mv_web/components/field_visibility_dropdown_component_test.exs b/test/mv_web/components/field_visibility_dropdown_component_test.exs
deleted file mode 100644
index 6e01afa..0000000
--- a/test/mv_web/components/field_visibility_dropdown_component_test.exs
+++ /dev/null
@@ -1,21 +0,0 @@
-defmodule MvWeb.Components.FieldVisibilityDropdownComponentTest do
- use MvWeb.ConnCase, async: true
- import Phoenix.LiveViewTest
-
- describe "field visibility dropdown in member view" do
- test "renders and toggles visibility", %{conn: conn} do
- conn = conn_with_oidc_user(conn)
- {:ok, view, _html} = live(conn, ~p"/members")
-
- # Renders Dropdown
- assert has_element?(view, "[data-testid='dropdown-menu']")
-
- # Opens Dropdown
- view |> element("[data-testid='dropdown-button']") |> render_click()
- assert has_element?(view, "#field-visibility-menu")
- assert has_element?(view, "button[phx-click='select_item'][phx-value-item='email']")
- assert has_element?(view, "button[phx-click='select_all']")
- assert has_element?(view, "button[phx-click='select_none']")
- end
- end
-end
diff --git a/test/mv_web/components/sort_header_component_test.exs b/test/mv_web/components/sort_header_component_test.exs
index e199635..2e6d4fe 100644
--- a/test/mv_web/components/sort_header_component_test.exs
+++ b/test/mv_web/components/sort_header_component_test.exs
@@ -150,27 +150,35 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
assert has_element?(view, "[data-testid='email'] .opacity-40")
end
- test "icon distribution shows exactly one active sort icon", %{conn: conn} do
+ test "icon distribution is correct for all fields", %{conn: conn} do
conn = conn_with_oidc_user(conn)
- # Test neutral state - only one field should have active sort icon
+ # Test neutral state - all fields except first name (default) should show neutral icons
{:ok, _view, html_neutral} = live(conn, "/members")
- # Count active icons (should be exactly 1 - ascending for default sort field)
+ # Count neutral icons (should be 7 - one for each field)
+ neutral_count =
+ html_neutral |> String.split("hero-chevron-up-down") |> length() |> Kernel.-(1)
+
+ assert neutral_count == 7
+
+ # Count active icons (should be 1)
up_count = html_neutral |> String.split("hero-chevron-up ") |> length() |> Kernel.-(1)
down_count = html_neutral |> String.split("hero-chevron-down ") |> length() |> Kernel.-(1)
+ assert up_count == 1
+ assert down_count == 0
- assert up_count == 1, "Expected exactly 1 ascending icon, got #{up_count}"
- assert down_count == 0, "Expected 0 descending icons, got #{down_count}"
+ # Test ascending state - one field active, others neutral
+ {:ok, _view, html_asc} = live(conn, "/members?sort_field=first_name&sort_order=asc")
- # Test descending state
- {:ok, _view, html_desc} = live(conn, "/members?sort_field=first_name&sort_order=desc")
+ # Should have exactly 1 ascending icon and 7 neutral icons
+ up_count = html_asc |> String.split("hero-chevron-up ") |> length() |> Kernel.-(1)
+ neutral_count = html_asc |> String.split("hero-chevron-up-down") |> length() |> Kernel.-(1)
+ down_count = html_asc |> String.split("hero-chevron-down ") |> length() |> Kernel.-(1)
- up_count = html_desc |> String.split("hero-chevron-up ") |> length() |> Kernel.-(1)
- down_count = html_desc |> String.split("hero-chevron-down ") |> length() |> Kernel.-(1)
-
- assert up_count == 0, "Expected 0 ascending icons, got #{up_count}"
- assert down_count == 1, "Expected exactly 1 descending icon, got #{down_count}"
+ assert up_count == 1
+ assert neutral_count == 7
+ assert down_count == 0
end
end
diff --git a/test/mv_web/live/member_live/index/field_selection_test.exs b/test/mv_web/live/member_live/index/field_selection_test.exs
deleted file mode 100644
index 9d6aa77..0000000
--- a/test/mv_web/live/member_live/index/field_selection_test.exs
+++ /dev/null
@@ -1,370 +0,0 @@
-defmodule MvWeb.MemberLive.Index.FieldSelectionTest do
- @moduledoc """
- Tests for FieldSelection module handling cookie/session/URL management.
- """
- use ExUnit.Case, async: true
-
- alias MvWeb.MemberLive.Index.FieldSelection
-
- describe "get_from_session/1" do
- test "returns empty map when session is empty" do
- assert FieldSelection.get_from_session(%{}) == %{}
- end
-
- test "returns empty map when session key is missing" do
- session = %{"other_key" => "value"}
- assert FieldSelection.get_from_session(session) == %{}
- end
-
- test "parses valid JSON from session" do
- json = Jason.encode!(%{"first_name" => true, "email" => false})
- session = %{"member_field_selection" => json}
-
- result = FieldSelection.get_from_session(session)
-
- assert result == %{"first_name" => true, "email" => false}
- end
-
- test "handles invalid JSON gracefully" do
- session = %{"member_field_selection" => "invalid json{["}
-
- result = FieldSelection.get_from_session(session)
-
- assert result == %{}
- end
-
- test "converts non-boolean values to true" do
- json = Jason.encode!(%{"first_name" => "true", "email" => 1, "street" => true})
- session = %{"member_field_selection" => json}
-
- result = FieldSelection.get_from_session(session)
-
- # All values should be booleans, non-booleans default to true
- assert result["first_name"] == true
- assert result["email"] == true
- assert result["street"] == true
- end
-
- test "handles nil session" do
- assert FieldSelection.get_from_session(nil) == %{}
- end
-
- test "handles non-map session" do
- assert FieldSelection.get_from_session("not a map") == %{}
- end
- end
-
- describe "save_to_session/2" do
- test "saves field selection to session as JSON" do
- session = %{}
- selection = %{"first_name" => true, "email" => false}
-
- result = FieldSelection.save_to_session(session, selection)
-
- assert Map.has_key?(result, "member_field_selection")
- assert Jason.decode!(result["member_field_selection"]) == selection
- end
-
- test "overwrites existing selection" do
- session = %{"member_field_selection" => Jason.encode!(%{"old" => true})}
- selection = %{"new" => true}
-
- result = FieldSelection.save_to_session(session, selection)
-
- assert Jason.decode!(result["member_field_selection"]) == selection
- end
-
- test "handles empty selection" do
- session = %{}
- selection = %{}
-
- result = FieldSelection.save_to_session(session, selection)
-
- assert Jason.decode!(result["member_field_selection"]) == %{}
- end
-
- test "handles invalid selection gracefully" do
- session = %{}
-
- result = FieldSelection.save_to_session(session, "not a map")
-
- assert result == session
- end
- end
-
- describe "get_from_cookie/1" do
- test "returns empty map when cookie header is missing" do
- conn = %Plug.Conn{}
-
- result = FieldSelection.get_from_cookie(conn)
-
- assert result == %{}
- end
-
- test "returns empty map when cookie is empty string" do
- conn = Plug.Conn.put_req_header(%Plug.Conn{}, "cookie", "")
-
- result = FieldSelection.get_from_cookie(conn)
-
- assert result == %{}
- end
-
- test "parses valid JSON from cookie" do
- selection = %{"first_name" => true, "email" => false}
- cookie_value = selection |> Jason.encode!() |> URI.encode()
- cookie_header = "member_field_selection=#{cookie_value}"
- conn = %Plug.Conn{} |> Plug.Conn.put_req_header("cookie", cookie_header)
-
- result = FieldSelection.get_from_cookie(conn)
-
- assert result == selection
- end
-
- test "handles invalid JSON in cookie gracefully" do
- cookie_value = URI.encode("invalid{[")
- cookie_header = "member_field_selection=#{cookie_value}"
- conn = %Plug.Conn{} |> Plug.Conn.put_req_header("cookie", cookie_header)
-
- result = FieldSelection.get_from_cookie(conn)
-
- assert result == %{}
- end
-
- test "handles cookie with other values" do
- selection = %{"street" => true}
- cookie_value = selection |> Jason.encode!() |> URI.encode()
- cookie_header = "other_cookie=value; member_field_selection=#{cookie_value}; another=test"
- conn = %Plug.Conn{} |> Plug.Conn.put_req_header("cookie", cookie_header)
-
- result = FieldSelection.get_from_cookie(conn)
-
- assert result == selection
- end
- end
-
- describe "save_to_cookie/2" do
- test "saves field selection to cookie" do
- conn = %Plug.Conn{}
- selection = %{"first_name" => true, "email" => false}
-
- result = FieldSelection.save_to_cookie(conn, selection)
-
- # Check that cookie is set
- assert result.resp_cookies["member_field_selection"]
- cookie = result.resp_cookies["member_field_selection"]
- assert cookie[:max_age] == 365 * 24 * 60 * 60
- assert cookie[:same_site] == "Lax"
- assert cookie[:http_only] == true
- end
-
- test "handles invalid selection gracefully" do
- conn = %Plug.Conn{}
-
- result = FieldSelection.save_to_cookie(conn, "not a map")
-
- assert result == conn
- end
- end
-
- describe "parse_from_url/1" do
- test "returns empty map when params is empty" do
- assert FieldSelection.parse_from_url(%{}) == %{}
- end
-
- test "returns empty map when fields parameter is missing" do
- params = %{"query" => "test", "sort_field" => "first_name"}
- assert FieldSelection.parse_from_url(params) == %{}
- end
-
- test "parses comma-separated field names" do
- params = %{"fields" => "first_name,email,street"}
-
- result = FieldSelection.parse_from_url(params)
-
- assert result == %{
- "first_name" => true,
- "email" => true,
- "street" => true
- }
- end
-
- test "handles custom field names" do
- params = %{"fields" => "custom_field_abc-123,custom_field_def-456"}
-
- result = FieldSelection.parse_from_url(params)
-
- assert result == %{
- "custom_field_abc-123" => true,
- "custom_field_def-456" => true
- }
- end
-
- test "handles mixed member and custom fields" do
- params = %{"fields" => "first_name,custom_field_123,email"}
-
- result = FieldSelection.parse_from_url(params)
-
- assert result == %{
- "first_name" => true,
- "custom_field_123" => true,
- "email" => true
- }
- end
-
- test "trims whitespace from field names" do
- params = %{"fields" => " first_name , email , street "}
-
- result = FieldSelection.parse_from_url(params)
-
- assert result == %{
- "first_name" => true,
- "email" => true,
- "street" => true
- }
- end
-
- test "handles empty fields string" do
- params = %{"fields" => ""}
- assert FieldSelection.parse_from_url(params) == %{}
- end
-
- test "handles nil fields parameter" do
- params = %{"fields" => nil}
- assert FieldSelection.parse_from_url(params) == %{}
- end
-
- test "filters out empty field names" do
- params = %{"fields" => "first_name,,email,"}
-
- result = FieldSelection.parse_from_url(params)
-
- assert result == %{
- "first_name" => true,
- "email" => true
- }
- end
-
- test "handles non-map params" do
- assert FieldSelection.parse_from_url(nil) == %{}
- assert FieldSelection.parse_from_url("not a map") == %{}
- end
- end
-
- describe "merge_sources/3" do
- test "merges all sources with URL having highest priority" do
- url_selection = %{"first_name" => false}
- session_selection = %{"first_name" => true, "email" => true}
- cookie_selection = %{"first_name" => true, "street" => true}
-
- result = FieldSelection.merge_sources(url_selection, session_selection, cookie_selection)
-
- # URL overrides session, session overrides cookie
- assert result["first_name"] == false
- assert result["email"] == true
- assert result["street"] == true
- end
-
- test "handles empty sources" do
- result = FieldSelection.merge_sources(%{}, %{}, %{})
-
- assert result == %{}
- end
-
- test "cookie only" do
- cookie_selection = %{"first_name" => true}
-
- result = FieldSelection.merge_sources(%{}, %{}, cookie_selection)
-
- assert result == %{"first_name" => true}
- end
-
- test "session overrides cookie" do
- session_selection = %{"first_name" => false}
- cookie_selection = %{"first_name" => true}
-
- result = FieldSelection.merge_sources(%{}, session_selection, cookie_selection)
-
- assert result["first_name"] == false
- end
-
- test "URL overrides everything" do
- url_selection = %{"first_name" => true}
- session_selection = %{"first_name" => false}
- cookie_selection = %{"first_name" => false}
-
- result = FieldSelection.merge_sources(url_selection, session_selection, cookie_selection)
-
- assert result["first_name"] == true
- end
-
- test "combines fields from all sources" do
- url_selection = %{"url_field" => true}
- session_selection = %{"session_field" => true}
- cookie_selection = %{"cookie_field" => true}
-
- result = FieldSelection.merge_sources(url_selection, session_selection, cookie_selection)
-
- assert result["url_field"] == true
- assert result["session_field"] == true
- assert result["cookie_field"] == true
- end
- end
-
- describe "to_url_param/1" do
- test "converts selection to comma-separated string" do
- selection = %{"first_name" => true, "email" => true, "street" => false}
-
- result = FieldSelection.to_url_param(selection)
-
- # Only visible fields should be included (order may vary)
- fields = String.split(result, ",") |> Enum.sort()
- assert fields == ["email", "first_name"]
- end
-
- test "handles empty selection" do
- assert FieldSelection.to_url_param(%{}) == ""
- end
-
- test "handles all fields hidden" do
- selection = %{"first_name" => false, "email" => false}
-
- result = FieldSelection.to_url_param(selection)
-
- assert result == ""
- end
-
- test "preserves field order" do
- selection = %{
- "z_field" => true,
- "a_field" => true,
- "m_field" => true
- }
-
- result = FieldSelection.to_url_param(selection)
-
- # Order should be preserved (map iteration order)
- assert String.contains?(result, "z_field")
- assert String.contains?(result, "a_field")
- assert String.contains?(result, "m_field")
- end
-
- test "handles custom fields" do
- selection = %{
- "first_name" => true,
- "custom_field_abc-123" => true,
- "email" => false
- }
-
- result = FieldSelection.to_url_param(selection)
-
- assert String.contains?(result, "first_name")
- assert String.contains?(result, "custom_field_abc-123")
- refute String.contains?(result, "email")
- end
-
- test "handles invalid input" do
- assert FieldSelection.to_url_param(nil) == ""
- assert FieldSelection.to_url_param("not a map") == ""
- end
- end
-end
diff --git a/test/mv_web/live/member_live/index/field_visibility_test.exs b/test/mv_web/live/member_live/index/field_visibility_test.exs
deleted file mode 100644
index 83ae06d..0000000
--- a/test/mv_web/live/member_live/index/field_visibility_test.exs
+++ /dev/null
@@ -1,336 +0,0 @@
-defmodule MvWeb.MemberLive.Index.FieldVisibilityTest do
- @moduledoc """
- Tests for FieldVisibility module handling field visibility merging logic.
- """
- use ExUnit.Case, async: true
-
- alias MvWeb.MemberLive.Index.FieldVisibility
-
- # Mock custom field structs for testing
- defp create_custom_field(id, name, show_in_overview \\ true) do
- %{
- id: id,
- name: name,
- show_in_overview: show_in_overview
- }
- end
-
- describe "get_all_available_fields/1" do
- test "returns member fields and custom fields" do
- custom_fields = [
- create_custom_field("cf1", "Custom Field 1"),
- create_custom_field("cf2", "Custom Field 2")
- ]
-
- result = FieldVisibility.get_all_available_fields(custom_fields)
-
- # Should include all member fields
- assert :first_name in result
- assert :email in result
- assert :street in result
-
- # Should include custom fields as strings
- assert "custom_field_cf1" in result
- assert "custom_field_cf2" in result
- end
-
- test "handles empty custom fields list" do
- result = FieldVisibility.get_all_available_fields([])
-
- # Should only have member fields
- assert :first_name in result
- assert :email in result
-
- refute Enum.any?(result, fn field ->
- is_binary(field) and String.starts_with?(field, "custom_field_")
- end)
- end
-
- test "includes all member fields from constants" do
- custom_fields = []
- result = FieldVisibility.get_all_available_fields(custom_fields)
-
- member_fields = Mv.Constants.member_fields()
-
- Enum.each(member_fields, fn field ->
- assert field in result
- end)
- end
- end
-
- describe "merge_with_global_settings/3" do
- test "user selection overrides global settings" do
- user_selection = %{"first_name" => false}
- settings = %{member_field_visibility: %{first_name: true, email: true}}
- custom_fields = []
-
- result = FieldVisibility.merge_with_global_settings(user_selection, settings, custom_fields)
-
- assert result["first_name"] == false
- assert result["email"] == true
- end
-
- test "falls back to global settings when user selection is empty" do
- user_selection = %{}
- settings = %{member_field_visibility: %{first_name: false, email: true}}
- custom_fields = []
-
- result = FieldVisibility.merge_with_global_settings(user_selection, settings, custom_fields)
-
- assert result["first_name"] == false
- assert result["email"] == true
- end
-
- test "defaults to true when field not in settings" do
- user_selection = %{}
- settings = %{member_field_visibility: %{first_name: false}}
- custom_fields = []
-
- result = FieldVisibility.merge_with_global_settings(user_selection, settings, custom_fields)
-
- # first_name from settings
- assert result["first_name"] == false
- # email defaults to true (not in settings)
- assert result["email"] == true
- end
-
- test "handles custom fields visibility" do
- user_selection = %{}
- settings = %{member_field_visibility: %{}}
-
- custom_fields = [
- create_custom_field("cf1", "Custom 1", true),
- create_custom_field("cf2", "Custom 2", false)
- ]
-
- result = FieldVisibility.merge_with_global_settings(user_selection, settings, custom_fields)
-
- assert result["custom_field_cf1"] == true
- assert result["custom_field_cf2"] == false
- end
-
- test "user selection overrides custom field visibility" do
- user_selection = %{"custom_field_cf1" => false}
- settings = %{member_field_visibility: %{}}
-
- custom_fields = [
- create_custom_field("cf1", "Custom 1", true)
- ]
-
- result = FieldVisibility.merge_with_global_settings(user_selection, settings, custom_fields)
-
- assert result["custom_field_cf1"] == false
- end
-
- test "handles string keys in settings (JSONB format)" do
- user_selection = %{}
- settings = %{member_field_visibility: %{"first_name" => false, "email" => true}}
- custom_fields = []
-
- result = FieldVisibility.merge_with_global_settings(user_selection, settings, custom_fields)
-
- assert result["first_name"] == false
- assert result["email"] == true
- end
-
- test "handles mixed atom and string keys in settings" do
- user_selection = %{}
- # Use string keys only (as JSONB would return)
- settings = %{member_field_visibility: %{"first_name" => false, "email" => true}}
- custom_fields = []
-
- result = FieldVisibility.merge_with_global_settings(user_selection, settings, custom_fields)
-
- assert result["first_name"] == false
- assert result["email"] == true
- end
-
- test "handles nil settings gracefully" do
- user_selection = %{}
- settings = %{member_field_visibility: nil}
- custom_fields = []
-
- result = FieldVisibility.merge_with_global_settings(user_selection, settings, custom_fields)
-
- # Should default all fields to true
- assert result["first_name"] == true
- assert result["email"] == true
- end
-
- test "handles missing member_field_visibility key" do
- user_selection = %{}
- settings = %{}
- custom_fields = []
-
- result = FieldVisibility.merge_with_global_settings(user_selection, settings, custom_fields)
-
- # Should default all fields to true
- assert result["first_name"] == true
- assert result["email"] == true
- end
-
- test "includes all fields in result" do
- user_selection = %{"first_name" => false}
- settings = %{member_field_visibility: %{email: true}}
-
- custom_fields = [
- create_custom_field("cf1", "Custom 1", true)
- ]
-
- result = FieldVisibility.merge_with_global_settings(user_selection, settings, custom_fields)
-
- # Should include all member fields
- member_fields = Mv.Constants.member_fields()
-
- Enum.each(member_fields, fn field ->
- assert Map.has_key?(result, Atom.to_string(field))
- end)
-
- # Should include custom fields
- assert Map.has_key?(result, "custom_field_cf1")
- end
- end
-
- describe "get_visible_fields/1" do
- test "returns only fields with true visibility" do
- selection = %{
- "first_name" => true,
- "email" => false,
- "street" => true,
- "custom_field_123" => false
- }
-
- result = FieldVisibility.get_visible_fields(selection)
-
- assert :first_name in result
- assert :street in result
- refute :email in result
- refute "custom_field_123" in result
- end
-
- test "converts member field strings to atoms" do
- selection = %{"first_name" => true, "email" => true}
-
- result = FieldVisibility.get_visible_fields(selection)
-
- assert :first_name in result
- assert :email in result
- end
-
- test "keeps custom fields as strings" do
- selection = %{"custom_field_abc-123" => true}
-
- result = FieldVisibility.get_visible_fields(selection)
-
- assert "custom_field_abc-123" in result
- end
-
- test "handles empty selection" do
- assert FieldVisibility.get_visible_fields(%{}) == []
- end
-
- test "handles all fields hidden" do
- selection = %{"first_name" => false, "email" => false}
-
- assert FieldVisibility.get_visible_fields(selection) == []
- end
-
- test "handles invalid input" do
- assert FieldVisibility.get_visible_fields(nil) == []
- end
- end
-
- describe "get_visible_member_fields/1" do
- test "returns only member fields that are visible" do
- selection = %{
- "first_name" => true,
- "email" => true,
- "custom_field_123" => true,
- "street" => false
- }
-
- result = FieldVisibility.get_visible_member_fields(selection)
-
- assert :first_name in result
- assert :email in result
- refute :street in result
- refute "custom_field_123" in result
- end
-
- test "filters out custom fields" do
- selection = %{
- "first_name" => true,
- "custom_field_123" => true,
- "custom_field_456" => true
- }
-
- result = FieldVisibility.get_visible_member_fields(selection)
-
- assert :first_name in result
- refute "custom_field_123" in result
- refute "custom_field_456" in result
- end
-
- test "handles empty selection" do
- assert FieldVisibility.get_visible_member_fields(%{}) == []
- end
-
- test "handles invalid input" do
- assert FieldVisibility.get_visible_member_fields(nil) == []
- end
- end
-
- describe "get_visible_custom_fields/1" do
- test "returns only custom fields that are visible" do
- selection = %{
- "first_name" => true,
- "custom_field_123" => true,
- "custom_field_456" => false,
- "email" => true
- }
-
- result = FieldVisibility.get_visible_custom_fields(selection)
-
- assert "custom_field_123" in result
- refute "custom_field_456" in result
- refute :first_name in result
- refute :email in result
- end
-
- test "filters out member fields" do
- selection = %{
- "first_name" => true,
- "email" => true,
- "custom_field_123" => true
- }
-
- result = FieldVisibility.get_visible_custom_fields(selection)
-
- assert "custom_field_123" in result
- refute :first_name in result
- refute :email in result
- end
-
- test "handles empty selection" do
- assert FieldVisibility.get_visible_custom_fields(%{}) == []
- end
-
- test "handles fields that look like custom fields but aren't" do
- selection = %{
- "custom_field_123" => true,
- "custom_field_like_name" => true,
- "not_custom_field" => true
- }
-
- result = FieldVisibility.get_visible_custom_fields(selection)
-
- assert "custom_field_123" in result
- assert "custom_field_like_name" in result
- refute "not_custom_field" in result
- end
-
- test "handles invalid input" do
- assert FieldVisibility.get_visible_custom_fields(nil) == []
- end
- end
-end
diff --git a/test/mv_web/member_live/index_custom_fields_display_test.exs b/test/mv_web/member_live/index_custom_fields_display_test.exs
index b720099..802cc8f 100644
--- a/test/mv_web/member_live/index_custom_fields_display_test.exs
+++ b/test/mv_web/member_live/index_custom_fields_display_test.exs
@@ -242,7 +242,7 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsDisplayTest do
assert html =~ "alice.private@example.com"
end
- test "shows empty cell for members without custom field values", %{
+ test "shows empty cell or placeholder for members without custom field values", %{
conn: conn,
member2: _member2,
field_show_string: field
@@ -253,14 +253,11 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsDisplayTest do
# The custom field column should exist
assert html =~ field.name
- # Member2 should exist in the table (first_name and last_name are in separate columns)
- assert html =~ "Bob"
- assert html =~ "Brown"
-
- # The value from member1 should appear (phone number)
+ # 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"
-
- # Note: Member2 doesn't have this custom field value, so the cell is empty
- # The implementation shows "" for missing values, which is the expected behavior
end
end
diff --git a/test/mv_web/member_live/index_field_visibility_test.exs b/test/mv_web/member_live/index_field_visibility_test.exs
deleted file mode 100644
index 6e1642a..0000000
--- a/test/mv_web/member_live/index_field_visibility_test.exs
+++ /dev/null
@@ -1,452 +0,0 @@
-defmodule MvWeb.MemberLive.IndexFieldVisibilityTest do
- @moduledoc """
- Integration tests for field visibility dropdown functionality.
-
- Tests cover:
- - Field selection dropdown rendering
- - Toggling field visibility
- - URL parameter persistence
- - Select all / deselect all
- - Integration with member list display
- - Custom fields visibility
- """
- use MvWeb.ConnCase, async: true
-
- import Phoenix.LiveViewTest
- require Ash.Query
-
- alias Mv.Membership.{CustomField, CustomFieldValue, Member}
-
- setup do
- # Create test members
- {:ok, member1} =
- Member
- |> Ash.Changeset.for_create(:create_member, %{
- first_name: "Alice",
- last_name: "Anderson",
- email: "alice@example.com",
- street: "Main St",
- city: "Berlin"
- })
- |> Ash.create()
-
- {:ok, member2} =
- Member
- |> Ash.Changeset.for_create(:create_member, %{
- first_name: "Bob",
- last_name: "Brown",
- email: "bob@example.com",
- street: "Second St",
- city: "Hamburg"
- })
- |> Ash.create()
-
- # Create custom field
- {:ok, custom_field} =
- CustomField
- |> Ash.Changeset.for_create(:create, %{
- name: "membership_number",
- value_type: :string,
- show_in_overview: true
- })
- |> Ash.create()
-
- # Create custom field values
- {:ok, _cfv1} =
- CustomFieldValue
- |> Ash.Changeset.for_create(:create, %{
- member_id: member1.id,
- custom_field_id: custom_field.id,
- value: "M001"
- })
- |> Ash.create()
-
- {:ok, _cfv2} =
- CustomFieldValue
- |> Ash.Changeset.for_create(:create, %{
- member_id: member2.id,
- custom_field_id: custom_field.id,
- value: "M002"
- })
- |> Ash.create()
-
- %{
- member1: member1,
- member2: member2,
- custom_field: custom_field
- }
- end
-
- describe "field visibility dropdown" do
- test "renders dropdown button", %{conn: conn} do
- conn = conn_with_oidc_user(conn)
- {:ok, _view, html} = live(conn, "/members")
-
- assert html =~ "Columns"
- assert html =~ ~s(aria-controls="field-visibility-menu")
- end
-
- test "opens dropdown when button is clicked", %{conn: conn} do
- conn = conn_with_oidc_user(conn)
- {:ok, view, _html} = live(conn, "/members")
-
- # Initially closed
- refute has_element?(view, "ul#field-visibility-menu")
-
- # Click button
- view
- |> element("button[aria-controls='field-visibility-menu']")
- |> render_click()
-
- # Should be open now
- assert has_element?(view, "ul#field-visibility-menu")
- end
-
- test "displays all member fields in dropdown", %{conn: conn} do
- conn = conn_with_oidc_user(conn)
- {:ok, view, _html} = live(conn, "/members")
-
- # Open dropdown
- view
- |> element("button[aria-controls='field-visibility-menu']")
- |> render_click()
-
- html = render(view)
-
- # Check for member fields (formatted labels)
- assert html =~ "First Name" or html =~ "first_name"
- assert html =~ "Email" or html =~ "email"
- assert html =~ "Street" or html =~ "street"
- end
-
- test "displays custom fields in dropdown", %{conn: conn, custom_field: custom_field} do
- conn = conn_with_oidc_user(conn)
- {:ok, view, _html} = live(conn, "/members")
-
- # Open dropdown
- view
- |> element("button[aria-controls='field-visibility-menu']")
- |> render_click()
-
- html = render(view)
-
- assert html =~ custom_field.name
- end
- end
-
- describe "field visibility toggling" do
- test "hiding a field removes it from display", %{conn: conn} do
- conn = conn_with_oidc_user(conn)
- {:ok, view, _html} = live(conn, "/members")
-
- # Verify email is visible initially
- html = render(view)
- assert html =~ "alice@example.com"
-
- # Open dropdown and hide email
- view
- |> element("button[aria-controls='field-visibility-menu']")
- |> render_click()
-
- view
- |> element("button[phx-click='select_item'][phx-value-item='email']")
- |> render_click()
-
- # Wait for update
- :timer.sleep(100)
-
- # Email should no longer be visible
- html = render(view)
- refute html =~ "alice@example.com"
- refute html =~ "bob@example.com"
- end
-
- test "hiding custom field removes it from display", %{conn: conn, custom_field: custom_field} do
- conn = conn_with_oidc_user(conn)
- {:ok, view, _html} = live(conn, "/members")
-
- # Verify custom field is visible initially
- html = render(view)
- assert html =~ "M001" or html =~ custom_field.name
-
- # Open dropdown and hide custom field
- view
- |> element("button[aria-controls='field-visibility-menu']")
- |> render_click()
-
- custom_field_id = custom_field.id
- custom_field_string = "custom_field_#{custom_field_id}"
-
- view
- |> element("button[phx-click='select_item'][phx-value-item='#{custom_field_string}']")
- |> render_click()
-
- # Wait for update
- :timer.sleep(100)
-
- # Custom field should no longer be visible
- html = render(view)
- refute html =~ "M001"
- refute html =~ "M002"
- end
- end
-
- describe "select all / deselect all" do
- test "select all makes all fields visible", %{conn: conn} do
- conn = conn_with_oidc_user(conn)
-
- # Start with some fields hidden
- {:ok, view, _html} = live(conn, "/members?fields=first_name")
-
- # Open dropdown
- view
- |> element("button[aria-controls='field-visibility-menu']")
- |> render_click()
-
- # Click select all
- view
- |> element("button[phx-click='select_all']")
- |> render_click()
-
- # Wait for update
- :timer.sleep(100)
-
- # All fields should be visible
- html = render(view)
- assert html =~ "alice@example.com"
- assert html =~ "Main St"
- assert html =~ "Berlin"
- end
-
- test "deselect all hides all fields except first_name", %{conn: conn} do
- conn = conn_with_oidc_user(conn)
- {:ok, view, _html} = live(conn, "/members")
-
- # Open dropdown
- view
- |> element("button[aria-controls='field-visibility-menu']")
- |> render_click()
-
- # Click deselect all
- view
- |> element("button[phx-click='select_none']")
- |> render_click()
-
- # Wait for update
- :timer.sleep(100)
-
- # Only first_name should be visible (it's always shown)
- html = render(view)
- # Email and street should be hidden
- refute html =~ "alice@example.com"
- refute html =~ "Main St"
- end
- end
-
- describe "URL parameter persistence" do
- test "field selection is persisted in URL", %{conn: conn} do
- conn = conn_with_oidc_user(conn)
- {:ok, view, _html} = live(conn, "/members")
-
- # Open dropdown and hide email
- view
- |> element("button[aria-controls='field-visibility-menu']")
- |> render_click()
-
- view
- |> element("button[phx-click='select_item'][phx-value-item='email']")
- |> render_click()
-
- # Wait for URL update
- :timer.sleep(100)
-
- # Check that URL contains fields parameter
- # Note: In LiveView tests, we check the rendered HTML for the updated state
- # The actual URL update happens via push_patch
- end
-
- test "loading page with fields parameter applies selection", %{conn: conn} do
- conn = conn_with_oidc_user(conn)
-
- # Load with first_name and city explicitly set in URL
- # Note: Other fields may still be visible due to global settings
- {:ok, view, _html} = live(conn, "/members?fields=first_name,city")
-
- html = render(view)
-
- # first_name and city should be visible
- assert html =~ "Alice"
- assert html =~ "Berlin"
-
- # Note: email and street may still be visible if global settings allow it
- # This test verifies that the URL parameters work, not that they hide other fields
- end
-
- test "fields parameter works with custom fields", %{conn: conn, custom_field: custom_field} do
- conn = conn_with_oidc_user(conn)
- custom_field_id = custom_field.id
-
- # Load with custom field visible
- {:ok, view, _html} =
- live(conn, "/members?fields=first_name,custom_field_#{custom_field_id}")
-
- html = render(view)
-
- # Custom field should be visible
- assert html =~ "M001" or html =~ custom_field.name
- end
- end
-
- describe "integration with global settings" do
- test "respects global settings when no user selection", %{conn: conn} do
- # This test would require setting up global settings
- # For now, we verify that the system works with default settings
- conn = conn_with_oidc_user(conn)
- {:ok, _view, html} = live(conn, "/members")
-
- # All fields should be visible by default
- assert html =~ "alice@example.com"
- assert html =~ "Main St"
- end
-
- test "user selection overrides global settings", %{conn: conn} do
- # This would require setting up global settings first
- # Then verifying that user selection takes precedence
- conn = conn_with_oidc_user(conn)
- {:ok, view, _html} = live(conn, "/members")
-
- # Hide a field via dropdown
- view
- |> element("button[aria-controls='field-visibility-menu']")
- |> render_click()
-
- view
- |> element("button[phx-click='select_item'][phx-value-item='email']")
- |> render_click()
-
- :timer.sleep(100)
-
- html = render(view)
- refute html =~ "alice@example.com"
- end
- end
-
- describe "edge cases" do
- test "handles empty fields parameter", %{conn: conn} do
- conn = conn_with_oidc_user(conn)
- {:ok, _view, html} = live(conn, "/members?fields=")
-
- # Should fall back to global settings
- assert html =~ "alice@example.com"
- end
-
- test "handles invalid field names in URL", %{conn: conn} do
- conn = conn_with_oidc_user(conn)
- {:ok, _view, html} = live(conn, "/members?fields=invalid_field,another_invalid")
-
- # Should ignore invalid fields and use defaults
- assert html =~ "alice@example.com"
- end
-
- test "handles custom field that doesn't exist", %{conn: conn} do
- conn = conn_with_oidc_user(conn)
- {:ok, _view, html} = live(conn, "/members?fields=first_name,custom_field_nonexistent")
-
- # Should work without errors
- assert html =~ "Alice"
- end
-
- test "handles rapid toggling", %{conn: conn} do
- conn = conn_with_oidc_user(conn)
- {:ok, view, _html} = live(conn, "/members")
-
- # Open dropdown
- view
- |> element("button[aria-controls='field-visibility-menu']")
- |> render_click()
-
- # Rapidly toggle a field multiple times
- for _ <- 1..5 do
- view
- |> element("button[phx-click='select_item'][phx-value-item='email']")
- |> render_click()
-
- :timer.sleep(50)
- end
-
- # Should still work correctly
- html = render(view)
- assert html =~ "Alice"
- end
- end
-
- describe "accessibility" do
- test "dropdown has proper ARIA attributes", %{conn: conn} do
- conn = conn_with_oidc_user(conn)
- {:ok, _view, html} = live(conn, "/members")
-
- assert html =~ ~s(aria-controls="field-visibility-menu")
- assert html =~ ~s(aria-haspopup="menu")
- assert html =~ ~s(role="button")
- end
-
- test "menu items have proper ARIA attributes when open", %{conn: conn} do
- conn = conn_with_oidc_user(conn)
- {:ok, view, _html} = live(conn, "/members")
-
- # Open dropdown
- view
- |> element("button[aria-controls='field-visibility-menu']")
- |> render_click()
-
- html = render(view)
-
- assert html =~ ~s(role="menu")
- assert html =~ ~s(role="menuitemcheckbox")
- assert html =~ ~s(aria-checked)
- end
-
- test "keyboard navigation works", %{conn: conn} do
- conn = conn_with_oidc_user(conn)
- {:ok, view, _html} = live(conn, "/members")
-
- # Open dropdown
- view
- |> element("button[aria-controls='field-visibility-menu']")
- |> render_click()
-
- # Check that elements are keyboard accessible
- html = render(view)
- assert html =~ ~s(tabindex="0")
- # Check that keyboard events are supported
- assert html =~ ~s(phx-keydown="select_item")
- assert html =~ ~s(phx-key="Enter")
- end
-
- test "keyboard activation with Enter key works", %{conn: conn} do
- conn = conn_with_oidc_user(conn)
- {:ok, view, _html} = live(conn, "/members")
-
- # Verify email is visible initially
- html = render(view)
- assert html =~ "alice@example.com"
-
- # Open dropdown
- view
- |> element("button[aria-controls='field-visibility-menu']")
- |> render_click()
-
- # Simulate Enter key press on email field button
- view
- |> element("button[phx-click='select_item'][phx-value-item='email']")
- |> render_keydown(%{key: "Enter"})
-
- # Wait for update
- :timer.sleep(100)
-
- # Email should no longer be visible
- html = render(view)
- refute html =~ "alice@example.com"
- end
- end
-end