Merge branch 'main' into feature/223_member_checkbox
Some checks failed
continuous-integration/drone/push Build is failing

This commit is contained in:
carla 2025-12-08 11:43:54 +01:00
commit 1b06f885bf
60 changed files with 8281 additions and 1644 deletions

View file

@ -32,9 +32,12 @@ defmodule MvWeb.MemberLive.Index do
alias Mv.Membership
alias MvWeb.MemberLive.Index.Formatter
alias MvWeb.Helpers.DateFormatter
alias MvWeb.MemberLive.Index.FieldSelection
alias MvWeb.MemberLive.Index.FieldVisibility
# Prefix used in sort field names for custom fields (e.g., "custom_field_<id>")
@custom_field_prefix "custom_field_"
@custom_field_prefix Mv.Constants.custom_field_prefix()
# Member fields that are loaded for the overview
# Uses constants from Mv.Constants to ensure consistency
@ -49,8 +52,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 +63,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 +77,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"))
@ -76,8 +99,15 @@ defmodule MvWeb.MemberLive.Index do
|> assign_new(:sort_order, fn -> :asc end)
|> assign(:paid_filter, nil)
|> assign(:selected_members, MapSet.new())
|> assign(:settings, settings)
|> assign(:custom_fields_visible, custom_fields_visible)
|> assign(: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_fields_visible,
FieldVisibility.get_visible_member_fields(initial_selection)
)
# We call handle params to use the query from the URL
{:ok, socket}
@ -182,6 +212,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
@ -250,24 +282,111 @@ defmodule MvWeb.MemberLive.Index do
)}
end
@impl true
def handle_info({:field_toggled, field_string, visible}, socket) do
# Update user field selection
new_selection = Map.put(socket.assigns.user_field_selection, field_string, visible)
# Save to session (cookie will be saved on next page load via handle_params)
socket = update_session_field_selection(socket, new_selection)
# Merge with global settings (use all_custom_fields to allow enabling globally hidden fields)
final_selection =
FieldVisibility.merge_with_global_settings(
new_selection,
socket.assigns.settings,
socket.assigns.all_custom_fields
)
# Get visible fields
visible_member_fields = FieldVisibility.get_visible_member_fields(final_selection)
visible_custom_fields = FieldVisibility.get_visible_custom_fields(final_selection)
socket =
socket
|> assign(:user_field_selection, final_selection)
|> assign(:member_fields_visible, visible_member_fields)
|> assign(:visible_custom_field_ids, extract_custom_field_ids(visible_custom_fields))
|> load_members()
|> prepare_dynamic_cols()
|> push_field_selection_url()
{:noreply, socket}
end
@impl true
def handle_info({:fields_selected, selection}, socket) do
# Save to session
socket = update_session_field_selection(socket, selection)
# Merge with global settings (use all_custom_fields for merging)
final_selection =
FieldVisibility.merge_with_global_settings(
selection,
socket.assigns.settings,
socket.assigns.all_custom_fields
)
# Get visible fields
visible_member_fields = FieldVisibility.get_visible_member_fields(final_selection)
visible_custom_fields = FieldVisibility.get_visible_custom_fields(final_selection)
socket =
socket
|> assign(:user_field_selection, final_selection)
|> assign(:member_fields_visible, visible_member_fields)
|> assign(:visible_custom_field_ids, extract_custom_field_ids(visible_custom_fields))
|> load_members()
|> prepare_dynamic_cols()
|> push_field_selection_url()
{:noreply, socket}
end
# -----------------------------------------------------------------
# Handle Params from the URL
# -----------------------------------------------------------------
@doc """
Handles URL parameter changes.
Parses query parameters for search query, sort field, sort order, and payment filter,
Parses query parameters for search query, sort field, sort order, and payment filter, 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)
|> maybe_update_paid_filter(params)
|> assign(:query, params["query"])
|> assign(:user_field_selection, final_selection)
|> assign(:member_fields_visible, visible_member_fields)
|> assign(:visible_custom_field_ids, extract_custom_field_ids(visible_custom_fields))
|> load_members()
|> prepare_dynamic_cols()
@ -280,10 +399,17 @@ defmodule MvWeb.MemberLive.Index do
# - `:custom_field` - The CustomField resource
# - `:render` - A function that formats the custom field value for a given member
#
# Only includes custom fields that are visible according to user field selection.
#
# Returns the socket with `:dynamic_cols` assigned.
defp prepare_dynamic_cols(socket) do
visible_custom_field_ids = socket.assigns[:visible_custom_field_ids] || []
# Use all_custom_fields to allow users to enable globally hidden custom fields
dynamic_cols =
Enum.map(socket.assigns.custom_fields_visible, fn custom_field ->
socket.assigns.all_custom_fields
|> 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 ->
@ -376,6 +502,58 @@ defmodule MvWeb.MemberLive.Index do
)}
end
# Builds query parameters including field selection
defp build_query_params(socket, base_params) do
# Use query from base_params if provided, otherwise fall back to socket.assigns.query
query_value = Map.get(base_params, "query") || socket.assigns.query || ""
base_params
|> Map.put("query", query_value)
|> maybe_add_field_selection(socket.assigns[:user_field_selection])
end
# Adds field selection to query params if present
defp maybe_add_field_selection(params, nil), do: params
defp maybe_add_field_selection(params, selection) when is_map(selection) do
fields_param = FieldSelection.to_url_param(selection)
if fields_param != "", do: Map.put(params, "fields", fields_param), else: params
end
defp maybe_add_field_selection(params, _), do: params
# Pushes URL with updated field selection
defp push_field_selection_url(socket) do
base_params = %{
"sort_field" => field_to_string(socket.assigns.sort_field),
"sort_order" => Atom.to_string(socket.assigns.sort_order)
}
# Include paid_filter if set
base_params =
case socket.assigns.paid_filter do
nil -> base_params
:paid -> Map.put(base_params, "paid_filter", "paid")
:not_paid -> Map.put(base_params, "paid_filter", "not_paid")
end
query_params = build_query_params(socket, base_params)
new_path = ~p"/members?#{query_params}"
push_patch(socket, to: new_path, replace: true)
end
# Converts field to string
defp field_to_string(field) when is_atom(field), do: Atom.to_string(field)
defp field_to_string(field) when is_binary(field), do: field
# Updates session field selection (stored in socket for now, actual session update via controller)
defp update_session_field_selection(socket, selection) do
# Store in socket for now - actual session persistence would require a controller
# This is a placeholder for future session persistence
assign(socket, :user_field_selection, selection)
end
# Builds URL query parameters map including all filter/sort state.
# Converts paid_filter atom to string for URL.
defp build_query_params(query, sort_field, sort_order, paid_filter) do
@ -434,9 +612,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)
@ -614,6 +792,18 @@ defmodule MvWeb.MemberLive.Index do
defp extract_custom_field_id(_), do: nil
# Extracts custom field IDs from visible custom field strings
# Format: "custom_field_<id>" -> <id>
defp extract_custom_field_ids(visible_custom_fields) do
Enum.map(visible_custom_fields, fn field_string ->
case String.split(field_string, @custom_field_prefix) do
["", id] -> id
_ -> nil
end
end)
|> Enum.filter(&(&1 != nil))
end
# Sorts members in memory by a custom field value.
#
# Process:
@ -920,31 +1110,6 @@ defmodule MvWeb.MemberLive.Index do
end
end
# Gets the list of member fields that should be visible in the overview.
#
# Reads the visibility configuration from Settings and returns only the fields
# where show_in_overview is true. Fields not configured in settings default to true.
#
# Performance: This function uses the already-loaded settings to avoid N+1 queries.
# Settings should be loaded once in mount/3 and passed to this function.
#
# Parameters:
# - `settings` - The settings struct loaded from the database
#
# Returns a list of atoms representing visible member field names.
#
# Fields are read from the global Constants module.
@spec get_visible_member_fields(map()) :: [atom()]
defp get_visible_member_fields(settings) do
# Get all eligible fields from the global constants
all_fields = Mv.Constants.member_fields()
# JSONB stores keys as strings
visibility_config = settings.member_field_visibility || %{}
# Filter to only return visible fields
Enum.filter(all_fields, fn field ->
Map.get(visibility_config, Atom.to_string(field), true)
end)
end
# Public helper function to format dates for use in templates
def format_date(date), do: DateFormatter.format_date(date)
end