feat: adds field visibility dropdown live component

This commit is contained in:
carla 2025-12-02 15:00:09 +01:00
parent f709edcf6f
commit 8b445cec48
6 changed files with 967 additions and 53 deletions

View file

@ -32,6 +32,8 @@ defmodule MvWeb.MemberLive.Index do
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_<id>")
@custom_field_prefix "custom_field_"
@ -49,8 +51,8 @@ defmodule MvWeb.MemberLive.Index do
payment filter, and member selection. Actual data loading happens in `handle_params/3`.
"""
@impl true
def mount(_params, _session, socket) do
# Load custom fields that should be shown in overview
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.
@ -60,6 +62,12 @@ 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
@ -68,6 +76,20 @@ defmodule MvWeb.MemberLive.Index do
{:error, _} -> %{member_field_visibility: %{}}
end
# Load user field selection from session
session_selection = FieldSelection.get_from_session(session)
# Get all available fields (for dropdown - includes ALL custom fields)
all_available_fields = FieldVisibility.get_all_available_fields(all_custom_fields)
# Merge session selection with global settings for initial state (use all_custom_fields)
initial_selection =
FieldVisibility.merge_with_global_settings(
session_selection,
settings,
all_custom_fields
)
socket =
socket
|> assign(:page_title, gettext("Members"))
@ -77,7 +99,14 @@ defmodule MvWeb.MemberLive.Index do
|> assign(:paid_filter, nil)
|> assign(:selected_members, MapSet.new())
|> assign(:custom_fields_visible, custom_fields_visible)
|> assign(:member_fields_visible, get_visible_member_fields(settings))
|> assign(:all_custom_fields, all_custom_fields)
|> assign(:all_available_fields, all_available_fields)
|> assign(:user_field_selection, initial_selection)
|> assign(:member_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}
@ -188,6 +217,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
@ -217,8 +248,11 @@ defmodule MvWeb.MemberLive.Index do
existing_sort_query = socket.assigns.sort_order
# Build the URL with queries
query_params =
build_query_params(q, existing_field_query, existing_sort_query, socket.assigns.paid_filter)
query_params = %{
"query" => q,
"sort_field" => existing_field_query,
"sort_order" => existing_sort_query
}
# Set the new path with params
new_path = ~p"/members?#{query_params}"
@ -231,50 +265,46 @@ defmodule MvWeb.MemberLive.Index do
)}
end
@impl true
def handle_info({:payment_filter_changed, filter}, socket) do
socket =
socket
|> assign(:paid_filter, filter)
|> load_members()
# Build the URL with all params including new filter
query_params =
build_query_params(
socket.assigns.query,
socket.assigns.sort_field,
socket.assigns.sort_order,
filter
)
new_path = ~p"/members?#{query_params}"
{:noreply,
push_patch(socket,
to: new_path,
replace: true
)}
end
# -----------------------------------------------------------------
# Handle Params from the URL
# -----------------------------------------------------------------
@doc """
Handles URL parameter changes.
Parses query parameters for search query, sort field, sort order, and payment filter,
Parses query parameters for search query, sort field, and sort order,
then loads members accordingly. This enables bookmarkable URLs and
browser back/forward navigation.
"""
@impl true
def handle_params(params, _url, socket) do
# Parse field selection from URL
url_selection = FieldSelection.parse_from_url(params)
# Merge with session selection (URL has priority)
merged_selection =
FieldSelection.merge_sources(
url_selection,
socket.assigns.user_field_selection,
%{}
)
# Merge with global settings (use all_custom_fields for merging)
final_selection =
FieldVisibility.merge_with_global_settings(
merged_selection,
socket.assigns.settings,
socket.assigns.all_custom_fields
)
# Get visible fields
visible_member_fields = FieldVisibility.get_visible_member_fields(final_selection)
visible_custom_fields = FieldVisibility.get_visible_custom_fields(final_selection)
socket =
socket
|> maybe_update_search(params)
|> maybe_update_sort(params)
|> maybe_update_paid_filter(params)
|> assign(:query, params["query"])
|> load_members()
|> load_members(params["query"])
|> prepare_dynamic_cols()
{:noreply, socket}
@ -286,10 +316,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 ->
@ -382,6 +418,88 @@ defmodule MvWeb.MemberLive.Index do
)}
end
# Builds query parameters including field selection
defp build_query_params(socket, base_params) do
base_params
|> Map.put("query", socket.assigns.query || "")
|> 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
# Builds query parameters including field selection
defp build_query_params(socket, base_params) do
base_params
|> Map.put("query", socket.assigns.query || "")
|> 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
# Builds URL query parameters map including all filter/sort state.
# Converts paid_filter atom to string for URL.
defp build_query_params(query, sort_field, sort_order, paid_filter) do
@ -440,9 +558,9 @@ defmodule MvWeb.MemberLive.Index do
|> Ash.Query.new()
|> 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)
@ -907,29 +1025,40 @@ defmodule MvWeb.MemberLive.Index do
# Gets the list of member fields that should be visible in the overview.
#
# Reads the visibility configuration from Settings and returns only the fields
# where show_in_overview is true. Fields not configured in settings default to true.
#
# Performance: This function uses the already-loaded settings to avoid N+1 queries.
# Settings should be loaded once in mount/3 and passed to this function.
# Filters the member field configurations to return only fields with show_in_overview: true.
#
# Parameters:
# - `settings` - The settings struct loaded from the database
#
# Returns a list of atoms representing visible member field names.
#
# Fields are read from the global Constants module.
@spec get_visible_member_fields(map()) :: [atom()]
defp get_visible_member_fields(settings) do
# Get all eligible fields from the global constants
all_fields = Mv.Constants.member_fields()
get_member_field_configurations(settings)
|> Enum.filter(fn {_field, show_in_overview} -> show_in_overview end)
|> Enum.map(fn {field, _show_in_overview} -> field end)
end
# JSONB stores keys as strings
visibility_config = settings.member_field_visibility || %{}
# 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)
# Filter to only return visible fields
Enum.filter(all_fields, fn field ->
Map.get(visibility_config, Atom.to_string(field), true)
{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: %{}
end