diff --git a/lib/membership/member.ex b/lib/membership/member.ex
index da69861..31a825b 100644
--- a/lib/membership/member.ex
+++ b/lib/membership/member.ex
@@ -42,6 +42,10 @@ defmodule Mv.Membership.Member do
@member_search_limit 10
@default_similarity_threshold 0.2
+ # Use constants from Mv.Constants for member fields
+ # This ensures consistency across the codebase
+ @member_fields Mv.Constants.member_fields()
+
postgres do
table "members"
repo Mv.Repo
@@ -58,21 +62,7 @@ defmodule Mv.Membership.Member do
# user_id is NOT in accept list to prevent direct foreign key manipulation
argument :user, :map, allow_nil?: true
- accept [
- :first_name,
- :last_name,
- :email,
- :birth_date,
- :paid,
- :phone_number,
- :join_date,
- :exit_date,
- :notes,
- :city,
- :street,
- :house_number,
- :postal_code
- ]
+ accept @member_fields
change manage_relationship(:custom_field_values, type: :create)
@@ -105,21 +95,7 @@ defmodule Mv.Membership.Member do
# user_id is NOT in accept list to prevent direct foreign key manipulation
argument :user, :map, allow_nil?: true
- accept [
- :first_name,
- :last_name,
- :email,
- :birth_date,
- :paid,
- :phone_number,
- :join_date,
- :exit_date,
- :notes,
- :city,
- :street,
- :house_number,
- :postal_code
- ]
+ accept @member_fields
change manage_relationship(:custom_field_values, on_match: :update, on_no_match: :create)
@@ -434,6 +410,70 @@ 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/membership/membership.ex b/lib/membership/membership.ex
index cb3691b..516448c 100644
--- a/lib/membership/membership.ex
+++ b/lib/membership/membership.ex
@@ -53,6 +53,7 @@ defmodule Mv.Membership do
# It's only used internally as fallback in get_settings/0
# Settings should be created via seed script
define :update_settings, action: :update
+ define :update_member_field_visibility, action: :update_member_field_visibility
end
end
@@ -123,4 +124,37 @@ defmodule Mv.Membership do
|> Ash.Changeset.for_update(:update, attrs)
|> Ash.update(domain: __MODULE__)
end
+
+ @doc """
+ Updates the member field visibility configuration.
+
+ This is a specialized action for updating only the member field visibility settings.
+ It validates that all keys are valid member fields and all values are booleans.
+
+ ## Parameters
+
+ - `settings` - The settings record to update
+ - `visibility_config` - A map of member field names (atoms) to boolean visibility values
+ (e.g., `%{street: false, house_number: false}`)
+
+ ## Returns
+
+ - `{:ok, updated_settings}` - Successfully updated settings
+ - `{:error, error}` - Validation or update error
+
+ ## Examples
+
+ iex> {:ok, settings} = Mv.Membership.get_settings()
+ iex> {:ok, updated} = Mv.Membership.update_member_field_visibility(settings, %{street: false, house_number: false})
+ iex> updated.member_field_visibility
+ %{street: false, house_number: false}
+
+ """
+ def update_member_field_visibility(settings, visibility_config) do
+ settings
+ |> Ash.Changeset.for_update(:update_member_field_visibility, %{
+ member_field_visibility: visibility_config
+ })
+ |> Ash.update(domain: __MODULE__)
+ end
end
diff --git a/lib/membership/setting.ex b/lib/membership/setting.ex
index 38624dc..3405a3f 100644
--- a/lib/membership/setting.ex
+++ b/lib/membership/setting.ex
@@ -9,6 +9,8 @@ defmodule Mv.Membership.Setting do
## Attributes
- `club_name` - The name of the association/club (required, cannot be empty)
+ - `member_field_visibility` - JSONB map storing visibility configuration for member fields
+ (e.g., `%{street: false, house_number: false}`). Fields not in the map default to `true`.
## Singleton Pattern
This resource uses a singleton pattern - there should only be one settings record.
@@ -28,6 +30,9 @@ defmodule Mv.Membership.Setting do
# Update club name
{:ok, updated} = Mv.Membership.update_settings(settings, %{club_name: "New Name"})
+
+ # Update member field visibility
+ {:ok, updated} = Mv.Membership.update_member_field_visibility(settings, %{street: false, house_number: false})
"""
use Ash.Resource,
domain: Mv.Membership,
@@ -49,18 +54,86 @@ defmodule Mv.Membership.Setting do
# Used only as fallback in get_settings/0 if settings don't exist
# Settings should normally be created via seed script
create :create do
- accept [:club_name]
+ accept [:club_name, :member_field_visibility]
end
update :update do
primary? true
- accept [:club_name]
+ require_atomic? false
+ accept [:club_name, :member_field_visibility]
+ end
+
+ update :update_member_field_visibility do
+ description "Updates the visibility configuration for member fields in the overview"
+ require_atomic? false
+ accept [:member_field_visibility]
+
+ change fn changeset, _context ->
+ visibility = Ash.Changeset.get_attribute(changeset, :member_field_visibility)
+
+ if visibility && is_map(visibility) do
+ valid_fields = Mv.Constants.member_fields()
+ # Normalize keys to atoms (JSONB may return string keys)
+ invalid_keys =
+ Enum.filter(visibility, fn {key, _value} ->
+ atom_key =
+ if is_atom(key) do
+ key
+ else
+ try do
+ String.to_existing_atom(key)
+ rescue
+ ArgumentError -> nil
+ end
+ end
+
+ atom_key && atom_key not in valid_fields
+ end)
+ |> Enum.map(fn {key, _value} -> key end)
+
+ if Enum.empty?(invalid_keys) do
+ changeset
+ else
+ Ash.Changeset.add_error(
+ changeset,
+ field: :member_field_visibility,
+ message: "Invalid member field keys: #{inspect(invalid_keys)}"
+ )
+ end
+ else
+ changeset
+ end
+ end
end
end
validations do
validate present(:club_name), on: [:create, :update]
validate string_length(:club_name, min: 1), on: [:create, :update]
+
+ # Validate that member_field_visibility map contains only boolean values
+ # This allows dynamic fields without hardcoding specific field names
+ validate fn changeset, _context ->
+ visibility = Ash.Changeset.get_attribute(changeset, :member_field_visibility)
+
+ if visibility && is_map(visibility) do
+ invalid_entries =
+ Enum.filter(visibility, fn {_key, value} ->
+ not is_boolean(value)
+ end)
+
+ if Enum.empty?(invalid_entries) do
+ :ok
+ else
+ {:error,
+ field: :member_field_visibility,
+ message: "All values in member_field_visibility must be booleans"}
+ end
+ else
+ :ok
+ end
+ end,
+ on: [:create, :update]
end
attributes do
@@ -75,6 +148,12 @@ defmodule Mv.Membership.Setting do
min_length: 1
]
+ attribute :member_field_visibility, :map,
+ allow_nil?: true,
+ public?: true,
+ description:
+ "Configuration for member field visibility in overview (JSONB map). Keys are member field names (atoms), values are booleans."
+
timestamps()
end
end
diff --git a/lib/mv/constants.ex b/lib/mv/constants.ex
new file mode 100644
index 0000000..cd8d3a4
--- /dev/null
+++ b/lib/mv/constants.ex
@@ -0,0 +1,23 @@
+defmodule Mv.Constants do
+ @moduledoc """
+ Module for defining constants and atoms.
+ """
+
+ @member_fields [
+ :first_name,
+ :last_name,
+ :email,
+ :birth_date,
+ :paid,
+ :phone_number,
+ :join_date,
+ :exit_date,
+ :notes,
+ :city,
+ :street,
+ :house_number,
+ :postal_code
+ ]
+
+ def member_fields, do: @member_fields
+end
diff --git a/lib/mv_web/components/core_components.ex b/lib/mv_web/components/core_components.ex
index b8fe0fc..4f6bf37 100644
--- a/lib/mv_web/components/core_components.ex
+++ b/lib/mv_web/components/core_components.ex
@@ -111,6 +111,126 @@ 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, default: nil
+
+ def dropdown_menu(assigns) do
+ unless Map.has_key?(assigns, :phx_target) do
+ raise ArgumentError, ":phx_target is required in dropdown_menu/1"
+ end
+
+ assigns =
+ assign_new(assigns, :items, fn -> [] end)
+ |> assign_new(:button_label, fn -> "Dropdown" end)
+ |> assign_new(:icon, fn -> nil end)
+ |> assign_new(:checkboxes, fn -> false end)
+ |> assign_new(:selected, fn -> %{} end)
+ |> assign_new(:open, fn -> false end)
+ |> assign_new(:show_select_buttons, fn -> false end)
+ |> assign(:phx_target, assigns.phx_target)
+ |> assign_new(:id, fn -> "dropdown-menu" end)
+
+ ~H"""
+
+
+
+
+ -
+
+
{gettext("Options")}
+
+
+
+
+
+
+
+
+
+ <%= for item <- @items do %>
+ -
+
+
+ <% end %>
+
+
+ """
+ end
+
@doc """
Renders an input with label and error messages.
diff --git a/lib/mv_web/components/field_visibility_dropdown_component.ex b/lib/mv_web/components/field_visibility_dropdown_component.ex
new file mode 100644
index 0000000..1ee0487
--- /dev/null
+++ b/lib/mv_web/components/field_visibility_dropdown_component.ex
@@ -0,0 +1,172 @@
+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(member_fields(all_fields), fn field ->
+ %{
+ value: field_to_string(field),
+ label: format_field_label(field)
+ }
+ end) ++
+ Enum.map(custom_fields(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 member_fields(nil), do: []
+
+ defp member_fields(fields) do
+ Enum.filter(fields, fn field ->
+ is_atom(field) ||
+ (is_binary(field) && not String.starts_with?(field, "custom_field_"))
+ end)
+ end
+
+ defp custom_fields(nil), do: []
+
+ defp custom_fields(fields) do
+ Enum.filter(fields, fn field ->
+ is_binary(field) && String.starts_with?(field, "custom_field_")
+ 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(&String.capitalize/1)
+ |> Enum.join(" ")
+ end
+
+ defp format_custom_field_label(field_string, custom_fields) do
+ case String.trim_leading(field_string, "custom_field_") do
+ "" ->
+ field_string
+
+ id ->
+ 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
+end
diff --git a/lib/mv_web/live/member_live/index.ex b/lib/mv_web/live/member_live/index.ex
index 85ee4fb..278543a 100644
--- a/lib/mv_web/live/member_live/index.ex
+++ b/lib/mv_web/live/member_live/index.ex
@@ -29,11 +29,20 @@ defmodule MvWeb.MemberLive.Index do
require Ash.Query
import Ash.Expr
+ alias Mv.Membership
alias MvWeb.MemberLive.Index.Formatter
+ 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 "custom_field_"
+ # Member fields that are loaded for the overview
+ # Uses constants from Mv.Constants to ensure consistency
+ # Note: :id is always included for member identification
+ # All member fields are loaded, but visibility is controlled via settings
+ @overview_fields [:id | Mv.Constants.member_fields()]
+
@doc """
Initializes the LiveView state.
@@ -41,8 +50,8 @@ defmodule MvWeb.MemberLive.Index do
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
+ def mount(_params, session, socket) do
+ # Load custom fields that should be shown in overview (for display)
# 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.
@@ -52,6 +61,34 @@ 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
+ {:ok, s} -> s
+ # Fallback if settings can't be loaded
+ {: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"))
@@ -59,7 +96,16 @@ defmodule MvWeb.MemberLive.Index do
|> assign_new(:sort_field, fn -> :first_name end)
|> assign_new(:sort_order, fn -> :asc end)
|> assign(:selected_members, [])
+ |> 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_field_configurations, get_member_field_configurations(settings))
+ |> assign(
+ :member_fields_visible,
+ FieldVisibility.get_visible_member_fields(initial_selection)
+ )
# We call handle params to use the query from the URL
{:ok, socket}
@@ -126,6 +172,8 @@ 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
@@ -146,17 +194,22 @@ defmodule MvWeb.MemberLive.Index do
@impl true
def handle_info({:search_changed, q}, socket) do
+ # Update query assign first
+ socket = assign(socket, :query, q)
+
+ # Load members with the new query
socket = load_members(socket, q)
existing_field_query = socket.assigns.sort_field
existing_sort_query = socket.assigns.sort_order
# Build the URL with queries
- query_params = %{
- "query" => q,
- "sort_field" => existing_field_query,
- "sort_order" => existing_sort_query
- }
+ query_params =
+ build_query_params(socket, %{
+ "query" => q,
+ "sort_field" => existing_field_query,
+ "sort_order" => existing_sort_query
+ })
# Set the new path with params
new_path = ~p"/members?#{query_params}"
@@ -169,22 +222,109 @@ 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
+ final_selection =
+ FieldVisibility.merge_with_global_settings(
+ new_selection,
+ socket.assigns.settings,
+ socket.assigns.custom_fields_visible
+ )
+
+ # 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(socket.assigns.query)
+ |> 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(socket.assigns.query)
+ |> 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, and sort order,
+ Parses query parameters for search query, sort field, sort order, and field selection,
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)
+ |> 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(params["query"])
|> prepare_dynamic_cols()
@@ -197,10 +337,16 @@ 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] || []
+
dynamic_cols =
- Enum.map(socket.assigns.custom_fields_visible, fn custom_field ->
+ socket.assigns.custom_fields_visible
+ |> Enum.filter(fn custom_field -> custom_field.id in visible_custom_field_ids end)
+ |> Enum.map(fn custom_field ->
%{
custom_field: custom_field,
render: fn member ->
@@ -276,11 +422,11 @@ defmodule MvWeb.MemberLive.Index do
field
end
- query_params = %{
- "query" => socket.assigns.query,
- "sort_field" => field_str,
- "sort_order" => Atom.to_string(order)
- }
+ query_params =
+ build_query_params(socket, %{
+ "sort_field" => field_str,
+ "sort_order" => Atom.to_string(order)
+ })
new_path = ~p"/members?#{query_params}"
@@ -291,6 +437,50 @@ 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
+ query_params =
+ build_query_params(socket, %{
+ "sort_field" => field_to_string(socket.assigns.sort_field),
+ "sort_order" => Atom.to_string(socket.assigns.sort_order)
+ })
+
+ 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
+
# Loads members from the database with custom field values and applies search/sort filters.
#
# Process:
@@ -313,22 +503,11 @@ defmodule MvWeb.MemberLive.Index do
query =
Mv.Membership.Member
|> Ash.Query.new()
- |> Ash.Query.select([
- :id,
- :first_name,
- :last_name,
- :email,
- :street,
- :house_number,
- :postal_code,
- :city,
- :phone_number,
- :join_date
- ])
+ |> Ash.Query.select(@overview_fields)
- # 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)
+ # 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)
# Apply the search filter first
query = apply_search_filter(query, search_query)
@@ -433,18 +612,13 @@ defmodule MvWeb.MemberLive.Index do
defp maybe_sort(query, _, _, _), do: {query, false}
# Validate that a field is sortable
+ # Uses member fields from constants, but excludes fields that don't make sense to sort
+ # (e.g., :notes is too long, :paid is boolean and not very useful for sorting)
defp valid_sort_field?(field) when is_atom(field) do
- valid_fields = [
- :first_name,
- :last_name,
- :email,
- :street,
- :house_number,
- :postal_code,
- :city,
- :phone_number,
- :join_date
- ]
+ # All member fields are sortable, but we exclude some that don't make sense
+ # :id is not in member_fields, but we don't want to sort by it anyway
+ non_sortable_fields = [:notes, :paid]
+ valid_fields = Mv.Constants.member_fields() -- non_sortable_fields
field in valid_fields or custom_field_sort?(field)
end
@@ -733,4 +907,75 @@ defmodule MvWeb.MemberLive.Index do
nil
end
end
+
+ # Gets the configuration for all member fields with their show_in_overview values.
+ #
+ # Reads the visibility configuration from Settings and returns a map with all member fields
+ # and their show_in_overview values (true or false). 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 map: %{field_name => show_in_overview}
+ #
+ # This can be used for:
+ # - Rendering the overview (filtering visible fields)
+ # - UI configuration dropdowns (showing all fields with their current state)
+ # - Dynamic field management
+ #
+ # Fields are read from the global Constants module.
+ @spec get_member_field_configurations(map()) :: %{atom() => boolean()}
+ defp get_member_field_configurations(settings) do
+ # Get all eligible fields from the global constants
+ all_fields = Mv.Constants.member_fields()
+
+ # Normalize visibility config (JSONB may return string keys)
+ visibility_config = normalize_visibility_config(settings.member_field_visibility || %{})
+
+ Enum.reduce(all_fields, %{}, fn field, acc ->
+ show_in_overview = Map.get(visibility_config, field, true)
+ Map.put(acc, field, show_in_overview)
+ end)
+ end
+
+
+ # Normalizes visibility config map keys from strings to atoms.
+ # JSONB in PostgreSQL converts atom keys to string keys when storing.
+ # This is a local helper to avoid N+1 queries by reusing the normalization logic.
+ 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: %{}
+
+ # 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_") do
+ ["", id] -> id
+ _ -> nil
+ end
+ end)
+ |> Enum.filter(&(&1 != nil))
+ end
end
diff --git a/lib/mv_web/live/member_live/index.html.heex b/lib/mv_web/live/member_live/index.html.heex
index 67fa804..e6076aa 100644
--- a/lib/mv_web/live/member_live/index.html.heex
+++ b/lib/mv_web/live/member_live/index.html.heex
@@ -2,6 +2,13 @@
<.header>
{gettext("Members")}
<:actions>
+ <.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}
+ />
<.button variant="primary" navigate={~p"/members/new"}>
<.icon name="hero-plus" /> {gettext("New Member")}
@@ -54,6 +61,7 @@
<:col
:let={member}
+ :if={:first_name in @member_fields_visible}
label={
~H"""
<.live_component
@@ -67,10 +75,29 @@
"""
}
>
- {member.first_name} {member.last_name}
+ {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}
+
+ <:col
+ :let={member}
+ :if={:email in @member_fields_visible}
label={
~H"""
<.live_component
@@ -88,6 +115,7 @@
<:col
:let={member}
+ :if={:street in @member_fields_visible}
label={
~H"""
<.live_component
@@ -105,6 +133,7 @@
<:col
:let={member}
+ :if={:house_number in @member_fields_visible}
label={
~H"""
<.live_component
@@ -122,6 +151,7 @@
<:col
:let={member}
+ :if={:postal_code in @member_fields_visible}
label={
~H"""
<.live_component
@@ -139,6 +169,7 @@
<:col
:let={member}
+ :if={:city in @member_fields_visible}
label={
~H"""
<.live_component
@@ -156,6 +187,7 @@
<:col
:let={member}
+ :if={:phone_number in @member_fields_visible}
label={
~H"""
<.live_component
@@ -173,6 +205,7 @@
<:col
:let={member}
+ :if={:join_date in @member_fields_visible}
label={
~H"""
<.live_component
diff --git a/lib/mv_web/live/member_live/index/field_selection.ex b/lib/mv_web/live/member_live/index/field_selection.ex
new file mode 100644
index 0000000..4b065f0
--- /dev/null
+++ b/lib/mv_web/live/member_live/index/field_selection.ex
@@ -0,0 +1,232 @@
+defmodule MvWeb.MemberLive.Index.FieldSelection do
+ @moduledoc """
+ Handles user-specific field selection persistence and URL parameter parsing.
+
+ This module manages:
+ - Reading/writing field selection from cookies (persistent storage)
+ - Reading/writing field selection from session (temporary storage)
+ - Parsing field selection from URL parameters
+ - Merging multiple sources with priority: URL > Session > Cookie
+
+ ## Data Format
+
+ Field selection is stored as a map:
+ ```elixir
+ %{
+ "first_name" => true,
+ "email" => true,
+ "street" => false,
+ "custom_field_abc-123" => true
+ }
+ ```
+
+ ## Cookie/Session Format
+
+ Stored as JSON string: `{"first_name":true,"email":true}`
+
+ ## URL Format
+
+ Comma-separated list: `?fields=first_name,email,custom_field_abc-123`
+ """
+
+ @cookie_name "member_field_selection"
+ @cookie_max_age 365 * 24 * 60 * 60
+ @session_key "member_field_selection"
+
+ @doc """
+ Reads field selection from session.
+
+ Returns a map of field names (strings) to boolean visibility values.
+ Returns empty map if no selection is stored.
+ """
+ @spec get_from_session(map()) :: %{String.t() => boolean()}
+ def get_from_session(session) when is_map(session) do
+ case Map.get(session, @session_key) do
+ nil -> %{}
+ json_string when is_binary(json_string) -> parse_json(json_string)
+ _ -> %{}
+ end
+ end
+
+ def get_from_session(_), do: %{}
+
+ @doc """
+ Saves field selection to session.
+
+ Converts the map to JSON string and stores it in the session.
+ """
+ @spec save_to_session(map(), %{String.t() => boolean()}) :: map()
+ def save_to_session(session, selection) when is_map(selection) do
+ json_string = Jason.encode!(selection)
+ Map.put(session, @session_key, json_string)
+ end
+
+ def save_to_session(session, _), do: session
+
+ @doc """
+ Reads field selection from cookie.
+
+ Returns a map of field names (strings) to boolean visibility values.
+ Returns empty map if no cookie is present.
+
+ Note: This function requires the connection to have cookies parsed.
+ 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
+ case Plug.Conn.get_req_header(conn, "cookie") do
+ nil ->
+ %{}
+
+ cookie_header ->
+ # Parse cookies manually from header
+ 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(fn {field, _visible} -> field end)
+ |> Enum.join(",")
+ 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} when is_boolean(value) -> {key, value}
+ {key, _value} -> {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
new file mode 100644
index 0000000..8dd36fc
--- /dev/null
+++ b/lib/mv_web/live/member_live/index/field_visibility.ex
@@ -0,0 +1,235 @@
+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
+ field_selection
+ |> Enum.filter(fn {field_string, visible} ->
+ visible && String.starts_with?(field_string, "custom_field_")
+ 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
+ Enum.reduce(custom_fields, %{}, fn custom_field, acc ->
+ field_string = "custom_field_#{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, "custom_field_") 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/repo/migrations/20251201115939_add_member_field_visibility_to_settings.exs b/priv/repo/migrations/20251201115939_add_member_field_visibility_to_settings.exs
new file mode 100644
index 0000000..6d278fb
--- /dev/null
+++ b/priv/repo/migrations/20251201115939_add_member_field_visibility_to_settings.exs
@@ -0,0 +1,21 @@
+defmodule Mv.Repo.Migrations.AddMemberFieldVisibilityToSettings 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(:settings) do
+ add :member_field_visibility, :map
+ end
+ end
+
+ def down do
+ alter table(:settings) do
+ remove :member_field_visibility
+ end
+ end
+end
diff --git a/priv/resource_snapshots/repo/custom_fields/20251201115939.json b/priv/resource_snapshots/repo/custom_fields/20251201115939.json
new file mode 100644
index 0000000..fabd84b
--- /dev/null
+++ b/priv/resource_snapshots/repo/custom_fields/20251201115939.json
@@ -0,0 +1,144 @@
+{
+ "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": "slug",
+ "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": "true",
+ "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": "D31160C95D3D32BA715D493DE2D2B8D6572E0EC68AE14B928D99975BC8A81542",
+ "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
+ },
+ {
+ "all_tenants?": false,
+ "base_filter": null,
+ "index_name": "custom_fields_unique_slug_index",
+ "keys": [
+ {
+ "type": "atom",
+ "value": "slug"
+ }
+ ],
+ "name": "unique_slug",
+ "nils_distinct?": true,
+ "where": null
+ }
+ ],
+ "multitenancy": {
+ "attribute": null,
+ "global": null,
+ "strategy": null
+ },
+ "repo": "Elixir.Mv.Repo",
+ "schema": null,
+ "table": "custom_fields"
+}
\ No newline at end of file
diff --git a/priv/resource_snapshots/repo/settings/20251201115939.json b/priv/resource_snapshots/repo/settings/20251201115939.json
new file mode 100644
index 0000000..4e635c4
--- /dev/null
+++ b/priv/resource_snapshots/repo/settings/20251201115939.json
@@ -0,0 +1,79 @@
+{
+ "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": "club_name",
+ "type": "text"
+ },
+ {
+ "allow_nil?": true,
+ "default": "nil",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "member_field_visibility",
+ "type": "map"
+ },
+ {
+ "allow_nil?": false,
+ "default": "fragment(\"(now() AT TIME ZONE 'utc')\")",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "inserted_at",
+ "type": "utc_datetime_usec"
+ },
+ {
+ "allow_nil?": false,
+ "default": "fragment(\"(now() AT TIME ZONE 'utc')\")",
+ "generated?": false,
+ "precision": null,
+ "primary_key?": false,
+ "references": null,
+ "scale": null,
+ "size": null,
+ "source": "updated_at",
+ "type": "utc_datetime_usec"
+ }
+ ],
+ "base_filter": null,
+ "check_constraints": [],
+ "custom_indexes": [],
+ "custom_statements": [],
+ "has_create_action": true,
+ "hash": "F2823210AA9E6476074A218375F64CD80E7F9E04EECC4E94D4C7FD31A773C016",
+ "identities": [],
+ "multitenancy": {
+ "attribute": null,
+ "global": null,
+ "strategy": null
+ },
+ "repo": "Elixir.Mv.Repo",
+ "schema": null,
+ "table": "settings"
+}
\ No newline at end of file
diff --git a/test/membership/member_field_visibility_test.exs b/test/membership/member_field_visibility_test.exs
new file mode 100644
index 0000000..46bdb74
--- /dev/null
+++ b/test/membership/member_field_visibility_test.exs
@@ -0,0 +1,80 @@
+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
+ {:ok, _updated_settings} =
+ Mv.Membership.update_settings(settings, %{
+ member_field_visibility: %{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
+ visibility_config =
+ Enum.reduce(fields_to_hide, %{}, fn field, acc ->
+ Map.put(acc, 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
new file mode 100644
index 0000000..81cd73b
--- /dev/null
+++ b/test/mv_web/components/field_visibility_dropdown_component_test.exs
@@ -0,0 +1,363 @@
+defmodule MvWeb.Components.FieldVisibilityDropdownComponentTest do
+ @moduledoc """
+ Tests for FieldVisibilityDropdownComponent LiveComponent.
+ """
+ use MvWeb.ConnCase, async: true
+
+ import Phoenix.LiveViewTest
+
+ alias MvWeb.Components.FieldVisibilityDropdownComponent
+
+ # Helper to create test assigns
+ defp create_assigns(overrides \\ %{}) do
+ default_assigns = %{
+ id: "test-dropdown",
+ all_fields: [:first_name, :email, :street, "custom_field_123"],
+ custom_fields: [
+ %{id: "123", name: "Custom Field 1"}
+ ],
+ selected_fields: %{
+ "first_name" => true,
+ "email" => true,
+ "street" => false,
+ "custom_field_123" => true
+ }
+ }
+
+ Map.merge(default_assigns, overrides)
+ end
+
+ describe "update/2" do
+ test "initializes with default values" do
+ assigns = create_assigns()
+
+ {:ok, socket} = FieldVisibilityDropdownComponent.update(assigns, %{})
+
+ assert socket.assigns.id == "test-dropdown"
+ assert socket.assigns.open == false
+ assert socket.assigns.all_fields == assigns.all_fields
+ assert socket.assigns.selected_fields == assigns.selected_fields
+ end
+
+ test "preserves existing open state" do
+ assigns = create_assigns()
+ existing_socket = %{assigns: %{open: true}}
+
+ {:ok, socket} = FieldVisibilityDropdownComponent.update(assigns, existing_socket)
+
+ assert socket.assigns.open == true
+ end
+
+ test "handles missing optional assigns" do
+ minimal_assigns = %{id: "test"}
+
+ {:ok, socket} = FieldVisibilityDropdownComponent.update(minimal_assigns, %{})
+
+ assert socket.assigns.all_fields == []
+ assert socket.assigns.custom_fields == []
+ assert socket.assigns.selected_fields == %{}
+ end
+ end
+
+ describe "render/1" do
+ test "renders dropdown button" do
+ assigns = create_assigns()
+
+ html = render_component(FieldVisibilityDropdownComponent, assigns)
+
+ assert html =~ "Columns"
+ assert html =~ "hero-adjustments-horizontal"
+ assert has_element?(html, "button[aria-controls='field-visibility-menu']")
+ end
+
+ test "renders dropdown menu when open" do
+ assigns = create_assigns() |> Map.put(:open, true)
+
+ html = render_component(FieldVisibilityDropdownComponent, assigns)
+
+ assert has_element?(html, "ul#field-visibility-menu")
+ assert html =~ "All"
+ assert html =~ "None"
+ end
+
+ test "does not render menu when closed" do
+ assigns = create_assigns() |> Map.put(:open, false)
+
+ html = render_component(FieldVisibilityDropdownComponent, assigns)
+
+ refute has_element?(html, "ul#field-visibility-menu")
+ end
+
+ test "renders member fields" do
+ assigns = create_assigns() |> Map.put(:open, true)
+
+ html = render_component(FieldVisibilityDropdownComponent, assigns)
+
+ # Field names should be formatted (first_name -> First Name)
+ assert html =~ "First Name" or html =~ "first_name"
+ assert html =~ "Email" or html =~ "email"
+ assert html =~ "Street" or html =~ "street"
+ end
+
+ test "renders custom fields when custom fields exist" do
+ assigns = create_assigns() |> Map.put(:open, true)
+
+ html = render_component(FieldVisibilityDropdownComponent, assigns)
+
+ # Custom field name
+ assert html =~ "Custom Field 1"
+ end
+
+ test "renders checkboxes with correct checked state" do
+ assigns = create_assigns() |> Map.put(:open, true)
+
+ html = render_component(FieldVisibilityDropdownComponent, assigns)
+
+ # first_name should be checked (aria-checked="true")
+ assert html =~ ~s(aria-checked="true")
+ assert html =~ ~s(phx-value-item="first_name")
+
+ # street should not be checked (aria-checked="false")
+ assert html =~ ~s(phx-value-item="street")
+ # Note: The visual checkbox state is handled by CSS classes and aria-checked attribute
+ end
+
+ test "includes accessibility attributes" do
+ assigns = create_assigns() |> Map.put(:open, true)
+
+ html = render_component(FieldVisibilityDropdownComponent, assigns)
+
+ assert html =~ ~s(aria-controls="field-visibility-menu")
+ assert html =~ ~s(aria-haspopup="menu")
+ assert html =~ ~s(role="button")
+ assert html =~ ~s(role="menu")
+ assert html =~ ~s(role="menuitemcheckbox")
+ end
+
+ test "formats member field labels correctly" do
+ assigns = create_assigns() |> Map.put(:open, true)
+
+ html = render_component(FieldVisibilityDropdownComponent, assigns)
+
+ # Field names should be formatted (first_name -> First Name)
+ assert html =~ "First Name" or html =~ "first_name"
+ end
+
+ test "uses custom field names from custom_fields prop" do
+ assigns =
+ create_assigns()
+ |> Map.put(:open, true)
+ |> Map.put(:custom_fields, [
+ %{id: "123", name: "Membership Number"}
+ ])
+
+ html = render_component(FieldVisibilityDropdownComponent, assigns)
+
+ assert html =~ "Membership Number"
+ end
+
+ test "falls back to ID when custom field not found" do
+ assigns =
+ create_assigns()
+ |> Map.put(:open, true)
+ # Empty custom fields list
+ |> Map.put(:custom_fields, [])
+
+ html = render_component(FieldVisibilityDropdownComponent, assigns)
+
+ # Should show something like "Custom Field 123"
+ assert html =~ "custom_field_123" or html =~ "Custom Field"
+ end
+ end
+
+ describe "handle_event/2" do
+ test "toggle_dropdown toggles open state" do
+ assigns = create_assigns()
+ {:ok, socket} = FieldVisibilityDropdownComponent.update(assigns, %{})
+
+ assert socket.assigns.open == false
+
+ {:noreply, socket} =
+ FieldVisibilityDropdownComponent.handle_event("toggle_dropdown", %{}, socket)
+
+ assert socket.assigns.open == true
+
+ {:noreply, socket} =
+ FieldVisibilityDropdownComponent.handle_event("toggle_dropdown", %{}, socket)
+
+ assert socket.assigns.open == false
+ end
+
+ test "close_dropdown sets open to false" do
+ assigns = create_assigns()
+ {:ok, socket} = FieldVisibilityDropdownComponent.update(assigns, %{})
+ socket = assign(socket, :open, true)
+
+ {:noreply, socket} =
+ FieldVisibilityDropdownComponent.handle_event("close_dropdown", %{}, socket)
+
+ assert socket.assigns.open == false
+ end
+
+ test "select_item toggles field visibility" do
+ assigns = create_assigns()
+ {:ok, socket} = FieldVisibilityDropdownComponent.update(assigns, %{})
+
+ assert socket.assigns.selected_fields["first_name"] == true
+
+ {:noreply, socket} =
+ FieldVisibilityDropdownComponent.handle_event(
+ "select_item",
+ %{"item" => "first_name"},
+ socket
+ )
+
+ assert socket.assigns.selected_fields["first_name"] == false
+
+ {:noreply, socket} =
+ FieldVisibilityDropdownComponent.handle_event(
+ "select_item",
+ %{"item" => "first_name"},
+ socket
+ )
+
+ assert socket.assigns.selected_fields["first_name"] == true
+ end
+
+ test "select_item defaults to true for missing fields" do
+ assigns = create_assigns()
+ {:ok, socket} = FieldVisibilityDropdownComponent.update(assigns, %{})
+
+ {:noreply, socket} =
+ FieldVisibilityDropdownComponent.handle_event(
+ "select_item",
+ %{"item" => "new_field"},
+ socket
+ )
+
+ # Toggled from default true
+ assert socket.assigns.selected_fields["new_field"] == false
+ end
+
+ test "select_item sends message to parent" do
+ assigns = create_assigns()
+ {:ok, socket} = FieldVisibilityDropdownComponent.update(assigns, %{})
+
+ FieldVisibilityDropdownComponent.handle_event(
+ "select_item",
+ %{"item" => "first_name"},
+ socket
+ )
+
+ # Check that message was sent (would be verified in integration test)
+ # For unit test, we just verify the state change
+ assert_receive {:field_toggled, "first_name", false}
+ end
+
+ test "select_all sets all fields to true" do
+ assigns = create_assigns()
+ {:ok, socket} = FieldVisibilityDropdownComponent.update(assigns, %{})
+
+ {:noreply, socket} =
+ FieldVisibilityDropdownComponent.handle_event("select_all", %{}, socket)
+
+ assert socket.assigns.selected_fields["first_name"] == true
+ assert socket.assigns.selected_fields["email"] == true
+ assert socket.assigns.selected_fields["street"] == true
+ assert socket.assigns.selected_fields["custom_field_123"] == true
+ end
+
+ test "select_all sends message to parent" do
+ assigns = create_assigns()
+ {:ok, socket} = FieldVisibilityDropdownComponent.update(assigns, %{})
+
+ FieldVisibilityDropdownComponent.handle_event("select_all", %{}, socket)
+
+ assert_receive {:fields_selected, selection}
+ assert selection["first_name"] == true
+ assert selection["email"] == true
+ end
+
+ test "select_none sets all fields to false" do
+ assigns = create_assigns()
+ {:ok, socket} = FieldVisibilityDropdownComponent.update(assigns, %{})
+
+ {:noreply, socket} =
+ FieldVisibilityDropdownComponent.handle_event("select_none", %{}, socket)
+
+ assert socket.assigns.selected_fields["first_name"] == false
+ assert socket.assigns.selected_fields["email"] == false
+ assert socket.assigns.selected_fields["street"] == false
+ assert socket.assigns.selected_fields["custom_field_123"] == false
+ end
+
+ test "select_none sends message to parent" do
+ assigns = create_assigns()
+ {:ok, socket} = FieldVisibilityDropdownComponent.update(assigns, %{})
+
+ FieldVisibilityDropdownComponent.handle_event("select_none", %{}, socket)
+
+ assert_receive {:fields_selected, selection}
+ assert selection["first_name"] == false
+ assert selection["email"] == false
+ end
+
+ test "handles custom field toggle" do
+ assigns = create_assigns()
+ {:ok, socket} = FieldVisibilityDropdownComponent.update(assigns, %{})
+
+ {:noreply, socket} =
+ FieldVisibilityDropdownComponent.handle_event(
+ "select_item",
+ %{"item" => "custom_field_123"},
+ socket
+ )
+
+ assert socket.assigns.selected_fields["custom_field_123"] == false
+ end
+ end
+
+ describe "integration with LiveView" do
+ test "component can be rendered in LiveView" do
+ conn = conn_with_oidc_user(build_conn())
+ {:ok, view, _html} = live(conn, "/members")
+
+ # Check that component is rendered
+ assert has_element?(view, "button[aria-controls='field-visibility-menu']")
+ end
+
+ test "clicking button opens dropdown" do
+ conn = conn_with_oidc_user(build_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 "toggling field updates selection" do
+ conn = conn_with_oidc_user(build_conn())
+ {:ok, view, _html} = live(conn, "/members")
+
+ # Open dropdown
+ view
+ |> element("button[aria-controls='field-visibility-menu']")
+ |> render_click()
+
+ # Toggle a field
+ view
+ |> element("button[phx-click='select_item'][phx-value-item='first_name']")
+ |> render_click()
+
+ # Component should update (verified by state change)
+ # In a real scenario, this would trigger a reload of members
+ end
+ 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
new file mode 100644
index 0000000..3c242c7
--- /dev/null
+++ b/test/mv_web/live/member_live/index/field_selection_test.exs
@@ -0,0 +1,346 @@
+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 is missing" 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
+ json = Jason.encode!(%{"first_name" => true, "email" => false})
+ conn = Plug.Conn.put_req_cookie(%Plug.Conn{}, "member_field_selection", json)
+
+ result = FieldSelection.get_from_cookie(conn)
+
+ assert result == %{"first_name" => true, "email" => false}
+ end
+
+ test "handles invalid JSON in cookie gracefully" do
+ conn = Plug.Conn.put_req_cookie(%Plug.Conn{}, "member_field_selection", "invalid{[")
+
+ result = FieldSelection.get_from_cookie(conn)
+
+ assert result == %{}
+ 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
+ assert result == "first_name,email"
+ 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
new file mode 100644
index 0000000..83ae06d
--- /dev/null
+++ b/test/mv_web/live/member_live/index/field_visibility_test.exs
@@ -0,0 +1,336 @@
+defmodule MvWeb.MemberLive.Index.FieldVisibilityTest do
+ @moduledoc """
+ Tests for FieldVisibility module handling field visibility merging logic.
+ """
+ use ExUnit.Case, async: true
+
+ alias MvWeb.MemberLive.Index.FieldVisibility
+
+ # Mock custom field structs for testing
+ defp create_custom_field(id, name, show_in_overview \\ true) do
+ %{
+ id: id,
+ name: name,
+ show_in_overview: show_in_overview
+ }
+ end
+
+ describe "get_all_available_fields/1" do
+ test "returns member fields and custom fields" do
+ custom_fields = [
+ create_custom_field("cf1", "Custom Field 1"),
+ create_custom_field("cf2", "Custom Field 2")
+ ]
+
+ result = FieldVisibility.get_all_available_fields(custom_fields)
+
+ # Should include all member fields
+ assert :first_name in result
+ assert :email in result
+ assert :street in result
+
+ # Should include custom fields as strings
+ assert "custom_field_cf1" in result
+ assert "custom_field_cf2" in result
+ end
+
+ test "handles empty custom fields list" do
+ result = FieldVisibility.get_all_available_fields([])
+
+ # Should only have member fields
+ assert :first_name in result
+ assert :email in result
+
+ refute Enum.any?(result, fn field ->
+ is_binary(field) and String.starts_with?(field, "custom_field_")
+ end)
+ end
+
+ test "includes all member fields from constants" do
+ custom_fields = []
+ result = FieldVisibility.get_all_available_fields(custom_fields)
+
+ member_fields = Mv.Constants.member_fields()
+
+ Enum.each(member_fields, fn field ->
+ assert field in result
+ end)
+ end
+ end
+
+ describe "merge_with_global_settings/3" do
+ test "user selection overrides global settings" do
+ user_selection = %{"first_name" => false}
+ settings = %{member_field_visibility: %{first_name: true, email: true}}
+ custom_fields = []
+
+ result = FieldVisibility.merge_with_global_settings(user_selection, settings, custom_fields)
+
+ assert result["first_name"] == false
+ assert result["email"] == true
+ end
+
+ test "falls back to global settings when user selection is empty" do
+ user_selection = %{}
+ settings = %{member_field_visibility: %{first_name: false, email: true}}
+ custom_fields = []
+
+ result = FieldVisibility.merge_with_global_settings(user_selection, settings, custom_fields)
+
+ assert result["first_name"] == false
+ assert result["email"] == true
+ end
+
+ test "defaults to true when field not in settings" do
+ user_selection = %{}
+ settings = %{member_field_visibility: %{first_name: false}}
+ custom_fields = []
+
+ result = FieldVisibility.merge_with_global_settings(user_selection, settings, custom_fields)
+
+ # first_name from settings
+ assert result["first_name"] == false
+ # email defaults to true (not in settings)
+ assert result["email"] == true
+ end
+
+ test "handles custom fields visibility" do
+ user_selection = %{}
+ settings = %{member_field_visibility: %{}}
+
+ custom_fields = [
+ create_custom_field("cf1", "Custom 1", true),
+ create_custom_field("cf2", "Custom 2", false)
+ ]
+
+ result = FieldVisibility.merge_with_global_settings(user_selection, settings, custom_fields)
+
+ assert result["custom_field_cf1"] == true
+ assert result["custom_field_cf2"] == false
+ end
+
+ test "user selection overrides custom field visibility" do
+ user_selection = %{"custom_field_cf1" => false}
+ settings = %{member_field_visibility: %{}}
+
+ custom_fields = [
+ create_custom_field("cf1", "Custom 1", true)
+ ]
+
+ result = FieldVisibility.merge_with_global_settings(user_selection, settings, custom_fields)
+
+ assert result["custom_field_cf1"] == false
+ end
+
+ test "handles string keys in settings (JSONB format)" do
+ user_selection = %{}
+ settings = %{member_field_visibility: %{"first_name" => false, "email" => true}}
+ custom_fields = []
+
+ result = FieldVisibility.merge_with_global_settings(user_selection, settings, custom_fields)
+
+ assert result["first_name"] == false
+ assert result["email"] == true
+ end
+
+ test "handles mixed atom and string keys in settings" do
+ user_selection = %{}
+ # Use string keys only (as JSONB would return)
+ settings = %{member_field_visibility: %{"first_name" => false, "email" => true}}
+ custom_fields = []
+
+ result = FieldVisibility.merge_with_global_settings(user_selection, settings, custom_fields)
+
+ assert result["first_name"] == false
+ assert result["email"] == true
+ end
+
+ test "handles nil settings gracefully" do
+ user_selection = %{}
+ settings = %{member_field_visibility: nil}
+ custom_fields = []
+
+ result = FieldVisibility.merge_with_global_settings(user_selection, settings, custom_fields)
+
+ # Should default all fields to true
+ assert result["first_name"] == true
+ assert result["email"] == true
+ end
+
+ test "handles missing member_field_visibility key" do
+ user_selection = %{}
+ settings = %{}
+ custom_fields = []
+
+ result = FieldVisibility.merge_with_global_settings(user_selection, settings, custom_fields)
+
+ # Should default all fields to true
+ assert result["first_name"] == true
+ assert result["email"] == true
+ end
+
+ test "includes all fields in result" do
+ user_selection = %{"first_name" => false}
+ settings = %{member_field_visibility: %{email: true}}
+
+ custom_fields = [
+ create_custom_field("cf1", "Custom 1", true)
+ ]
+
+ result = FieldVisibility.merge_with_global_settings(user_selection, settings, custom_fields)
+
+ # Should include all member fields
+ member_fields = Mv.Constants.member_fields()
+
+ Enum.each(member_fields, fn field ->
+ assert Map.has_key?(result, Atom.to_string(field))
+ end)
+
+ # Should include custom fields
+ assert Map.has_key?(result, "custom_field_cf1")
+ end
+ end
+
+ describe "get_visible_fields/1" do
+ test "returns only fields with true visibility" do
+ selection = %{
+ "first_name" => true,
+ "email" => false,
+ "street" => true,
+ "custom_field_123" => false
+ }
+
+ result = FieldVisibility.get_visible_fields(selection)
+
+ assert :first_name in result
+ assert :street in result
+ refute :email in result
+ refute "custom_field_123" in result
+ end
+
+ test "converts member field strings to atoms" do
+ selection = %{"first_name" => true, "email" => true}
+
+ result = FieldVisibility.get_visible_fields(selection)
+
+ assert :first_name in result
+ assert :email in result
+ end
+
+ test "keeps custom fields as strings" do
+ selection = %{"custom_field_abc-123" => true}
+
+ result = FieldVisibility.get_visible_fields(selection)
+
+ assert "custom_field_abc-123" in result
+ end
+
+ test "handles empty selection" do
+ assert FieldVisibility.get_visible_fields(%{}) == []
+ end
+
+ test "handles all fields hidden" do
+ selection = %{"first_name" => false, "email" => false}
+
+ assert FieldVisibility.get_visible_fields(selection) == []
+ end
+
+ test "handles invalid input" do
+ assert FieldVisibility.get_visible_fields(nil) == []
+ end
+ end
+
+ describe "get_visible_member_fields/1" do
+ test "returns only member fields that are visible" do
+ selection = %{
+ "first_name" => true,
+ "email" => true,
+ "custom_field_123" => true,
+ "street" => false
+ }
+
+ result = FieldVisibility.get_visible_member_fields(selection)
+
+ assert :first_name in result
+ assert :email in result
+ refute :street in result
+ refute "custom_field_123" in result
+ end
+
+ test "filters out custom fields" do
+ selection = %{
+ "first_name" => true,
+ "custom_field_123" => true,
+ "custom_field_456" => true
+ }
+
+ result = FieldVisibility.get_visible_member_fields(selection)
+
+ assert :first_name in result
+ refute "custom_field_123" in result
+ refute "custom_field_456" in result
+ end
+
+ test "handles empty selection" do
+ assert FieldVisibility.get_visible_member_fields(%{}) == []
+ end
+
+ test "handles invalid input" do
+ assert FieldVisibility.get_visible_member_fields(nil) == []
+ end
+ end
+
+ describe "get_visible_custom_fields/1" do
+ test "returns only custom fields that are visible" do
+ selection = %{
+ "first_name" => true,
+ "custom_field_123" => true,
+ "custom_field_456" => false,
+ "email" => true
+ }
+
+ result = FieldVisibility.get_visible_custom_fields(selection)
+
+ assert "custom_field_123" in result
+ refute "custom_field_456" in result
+ refute :first_name in result
+ refute :email in result
+ end
+
+ test "filters out member fields" do
+ selection = %{
+ "first_name" => true,
+ "email" => true,
+ "custom_field_123" => true
+ }
+
+ result = FieldVisibility.get_visible_custom_fields(selection)
+
+ assert "custom_field_123" in result
+ refute :first_name in result
+ refute :email in result
+ end
+
+ test "handles empty selection" do
+ assert FieldVisibility.get_visible_custom_fields(%{}) == []
+ end
+
+ test "handles fields that look like custom fields but aren't" do
+ selection = %{
+ "custom_field_123" => true,
+ "custom_field_like_name" => true,
+ "not_custom_field" => true
+ }
+
+ result = FieldVisibility.get_visible_custom_fields(selection)
+
+ assert "custom_field_123" in result
+ assert "custom_field_like_name" in result
+ refute "not_custom_field" in result
+ end
+
+ test "handles invalid input" do
+ assert FieldVisibility.get_visible_custom_fields(nil) == []
+ end
+ end
+end
diff --git a/test/mv_web/member_live/index_field_visibility_test.exs b/test/mv_web/member_live/index_field_visibility_test.exs
new file mode 100644
index 0000000..c4241fe
--- /dev/null
+++ b/test/mv_web/member_live/index_field_visibility_test.exs
@@ -0,0 +1,509 @@
+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 "showing a hidden field adds it to display", %{conn: conn} do
+ conn = conn_with_oidc_user(conn)
+
+ # Start with only first_name and street explicitly set in URL
+ # Note: Other fields may still be visible due to global settings
+ {:ok, view, _html} = live(conn, "/members?fields=first_name,street")
+
+ # Verify first_name and street are visible
+ html = render(view)
+ assert html =~ "Alice"
+ assert html =~ "Main St"
+
+ # Open dropdown and toggle email (to ensure it's visible)
+ view
+ |> element("button[aria-controls='field-visibility-menu']")
+ |> render_click()
+
+ # If email is not visible, toggle it to make it visible
+ # If it's already visible, toggle it off and on again
+ view
+ |> element("button[phx-click='select_item'][phx-value-item='email']")
+ |> render_click()
+
+ # Wait for update
+ :timer.sleep(100)
+
+ # Email should now be visible
+ html = render(view)
+ assert html =~ "alice@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 Space")
+ 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("Enter")
+
+ # Wait for update
+ :timer.sleep(100)
+
+ # Email should no longer be visible
+ html = render(view)
+ refute html =~ "alice@example.com"
+ end
+
+ test "keyboard activation with Space 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 Space key press on email field button
+ view
+ |> element("button[phx-click='select_item'][phx-value-item='email']")
+ |> render_keydown(" ")
+
+ # Wait for update
+ :timer.sleep(100)
+
+ # Email should no longer be visible
+ html = render(view)
+ refute html =~ "alice@example.com"
+ end
+ end
+end
diff --git a/test/mv_web/member_live/index_member_fields_display_test.exs b/test/mv_web/member_live/index_member_fields_display_test.exs
new file mode 100644
index 0000000..c4a5b9f
--- /dev/null
+++ b/test/mv_web/member_live/index_member_fields_display_test.exs
@@ -0,0 +1,64 @@
+defmodule MvWeb.MemberLive.IndexMemberFieldsDisplayTest do
+ use MvWeb.ConnCase, async: true
+ import Phoenix.LiveViewTest
+ require Ash.Query
+
+ alias Mv.Membership.Member
+
+ setup do
+ {:ok, member1} =
+ Member
+ |> Ash.Changeset.for_create(:create_member, %{
+ first_name: "Alice",
+ last_name: "Anderson",
+ email: "alice@example.com",
+ street: "Main Street",
+ house_number: "123",
+ postal_code: "12345",
+ city: "Berlin",
+ phone_number: "+49123456789",
+ join_date: ~D[2020-01-15]
+ })
+ |> Ash.create()
+
+ {:ok, member2} =
+ Member
+ |> Ash.Changeset.for_create(:create_member, %{
+ first_name: "Bob",
+ last_name: "Brown",
+ email: "bob@example.com"
+ })
+ |> Ash.create()
+
+ %{
+ member1: member1,
+ member2: member2
+ }
+ end
+
+ test "shows multiple members correctly", %{conn: conn, member1: m1, member2: m2} do
+ conn = conn_with_oidc_user(conn)
+ {:ok, _view, html} = live(conn, "/members")
+
+ for m <- [m1, m2], field <- [m.first_name, m.last_name, m.email] do
+ assert html =~ field
+ end
+ end
+
+ test "respects show_in_overview config", %{conn: conn, member1: m} do
+ {:ok, settings} = Mv.Membership.get_settings()
+ fields_to_hide = [:street, :house_number]
+
+ {:ok, _} =
+ Mv.Membership.update_settings(settings, %{
+ member_field_visibility: Map.new(fields_to_hide, &{&1, false})
+ })
+
+ conn = conn_with_oidc_user(conn)
+ {:ok, _view, html} = live(conn, "/members")
+
+ assert html =~ "Email"
+ assert html =~ m.email
+ refute html =~ m.street
+ end
+end