feat(member): let members tailor overview density and visible columns

The View dropdown drives row density and whether the Member and Address
fields render as composite cells or split into their underlying columns;
the column manager toggles visibility and resets to the curated default.
All choices persist per browser through the URL/session/cookie/global
chain, so a saved layout survives reloads without a per-account store.
This commit is contained in:
Simon 2026-07-06 10:45:53 +02:00
parent af2cc2e0d4
commit dfc616257d
21 changed files with 1643 additions and 263 deletions

View file

@ -41,6 +41,7 @@ defmodule MvWeb.MemberLive.Index do
alias MvWeb.MemberLive.Index.Formatter
alias MvWeb.MemberLive.Index.MembershipFeeStatus
alias MvWeb.MemberLive.Index.OverviewQuery
alias MvWeb.MemberLive.Index.ViewSettings
require Ash.Query
require Logger
@ -123,16 +124,31 @@ defmodule MvWeb.MemberLive.Index do
# Load user field selection from session
session_selection = FieldSelection.get_from_session(session)
# FIX: ensure dropdown doesnt show duplicate fields (e.g. membership fee status twice)
# Resolve the per-browser view settings first: the columns the manager offers
# (composite vs. constituent Name/Address columns) follow these settings (§7b).
view_settings = ViewSettings.resolve(session, connect_conn(socket), connect_params(socket))
compact_member = view_settings.compact_member
member_include_email = view_settings.member_include_email
compact_address = view_settings.compact_address
# The dropdown offers exactly the columns applicable to the current view
# settings, so composite and constituent columns are never both listed.
all_available_fields =
all_custom_fields
|> FieldVisibility.get_all_available_fields()
FieldVisibility.get_offered_fields(
all_custom_fields,
compact_member,
member_include_email,
compact_address
)
initial_selection =
FieldVisibility.merge_with_global_settings(
session_selection,
settings,
all_custom_fields
all_custom_fields,
compact_member: compact_member,
member_include_email: member_include_email,
compact_address: compact_address
)
socket =
@ -183,6 +199,7 @@ defmodule MvWeb.MemberLive.Index do
|> assign(:mailto_bcc, "")
|> assign(:recipient_count, 0)
|> assign(:mailto_disabled?, false)
|> assign_view_settings(view_settings)
|> stream_configure(:members, dom_id: &"row-#{&1.id}")
|> stream(:members, [])
|> assign_export_payload()
@ -385,6 +402,11 @@ defmodule MvWeb.MemberLive.Index do
- `{:fields_selected, selection}` - Select all/deselect all event from FieldVisibilityDropdownComponent
"""
@impl true
def handle_info({:view_setting_toggled, key}, socket) do
{:noreply, toggle_view_setting(socket, key)}
end
@impl true
def handle_info({:search_changed, q}, socket) do
socket =
@ -604,34 +626,10 @@ defmodule MvWeb.MemberLive.Index do
@impl true
def handle_info({:field_toggled, field_string, visible}, socket) do
new_selection = Map.put(socket.assigns.user_field_selection, field_string, visible)
socket = update_session_field_selection(socket, new_selection)
final_selection =
FieldVisibility.merge_with_global_settings(
new_selection,
socket.assigns.settings,
socket.assigns.all_custom_fields
)
visible_member_fields =
final_selection
|> FieldVisibility.get_visible_member_fields()
|> Enum.uniq()
visible_member_fields_db = FieldVisibility.get_visible_member_fields_db(final_selection)
visible_member_fields_computed =
FieldVisibility.get_visible_member_fields_computed(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(:member_fields_visible_db, visible_member_fields_db)
|> assign(:member_fields_visible_computed, visible_member_fields_computed)
|> assign(:visible_custom_field_ids, extract_custom_field_ids(visible_custom_fields))
|> assign_field_visibility(new_selection)
|> load_members()
|> prepare_dynamic_cols()
|> update_selection_assigns()
@ -641,35 +639,29 @@ defmodule MvWeb.MemberLive.Index do
end
@impl true
def handle_info({:fields_selected, selection}, socket) do
socket = update_session_field_selection(socket, selection)
def handle_info({:fields_reset}, socket) do
# Reset restores the curated default column set (§1.6): recompute the default
# visibility from the global settings and re-apply it as the selection.
{cm, ie, ca} = view_flags(socket)
final_selection =
default_selection =
FieldVisibility.merge_with_global_settings(
selection,
%{},
socket.assigns.settings,
socket.assigns.all_custom_fields
socket.assigns.all_custom_fields,
compact_member: cm,
member_include_email: ie,
compact_address: ca
)
visible_member_fields =
final_selection
|> FieldVisibility.get_visible_member_fields()
|> Enum.uniq()
visible_member_fields_db = FieldVisibility.get_visible_member_fields_db(final_selection)
visible_member_fields_computed =
FieldVisibility.get_visible_member_fields_computed(final_selection)
visible_custom_fields = FieldVisibility.get_visible_custom_fields(final_selection)
handle_info({:fields_selected, default_selection}, socket)
end
@impl true
def handle_info({:fields_selected, selection}, socket) do
socket =
socket
|> assign(:user_field_selection, final_selection)
|> assign(:member_fields_visible, visible_member_fields)
|> assign(:member_fields_visible_db, visible_member_fields_db)
|> assign(:member_fields_visible_computed, visible_member_fields_computed)
|> assign(:visible_custom_field_ids, extract_custom_field_ids(visible_custom_fields))
|> assign_field_visibility(selection)
|> load_members()
|> prepare_dynamic_cols()
|> update_selection_assigns()
@ -697,18 +689,6 @@ defmodule MvWeb.MemberLive.Index do
url_selection = FieldSelection.parse_from_url(params)
final_selection = compute_final_field_selection(fields_in_url?, url_selection, socket)
visible_member_fields =
final_selection
|> FieldVisibility.get_visible_member_fields()
|> Enum.uniq()
visible_member_fields_db = FieldVisibility.get_visible_member_fields_db(final_selection)
visible_member_fields_computed =
FieldVisibility.get_visible_member_fields_computed(final_selection)
visible_custom_fields = FieldVisibility.get_visible_custom_fields(final_selection)
socket =
socket
|> maybe_update_search(params)
@ -721,11 +701,7 @@ defmodule MvWeb.MemberLive.Index do
|> maybe_update_show_current_cycle(params)
|> assign(:fields_in_url?, fields_in_url?)
|> assign(:query, params["query"])
|> assign(:user_field_selection, final_selection)
|> assign(:member_fields_visible, visible_member_fields)
|> assign(:member_fields_visible_db, visible_member_fields_db)
|> assign(:member_fields_visible_computed, visible_member_fields_computed)
|> assign(:visible_custom_field_ids, extract_custom_field_ids(visible_custom_fields))
|> assign_visibility_derivations(final_selection)
|> assign(:selected_member_id, parse_highlight_param(params["highlight"]))
next_sig = build_signature(socket)
@ -863,8 +839,52 @@ defmodule MvWeb.MemberLive.Index do
push_reload(socket, new_path)
end
defp update_session_field_selection(socket, selection) do
assign(socket, :user_field_selection, selection)
# The current composite view flags; the offered column set is scoped to these.
defp view_flags(socket) do
{socket.assigns.compact_member, socket.assigns.member_include_email,
socket.assigns.compact_address}
end
# Merges a raw selection with the global settings scoped to the current view
# settings, then assigns it together with the visibility-derived assigns.
defp assign_field_visibility(socket, selection) do
{cm, ie, ca} = view_flags(socket)
final =
FieldVisibility.merge_with_global_settings(
selection,
socket.assigns.settings,
socket.assigns.all_custom_fields,
compact_member: cm,
member_include_email: ie,
compact_address: ca
)
assign_visibility_derivations(socket, final)
end
# Assigns user_field_selection and every visibility-derived assign the table and
# export payload read from an already merged (offered-scoped) selection.
defp assign_visibility_derivations(socket, final_selection) do
visible_member_fields =
final_selection
|> FieldVisibility.get_visible_member_fields()
|> Enum.uniq()
visible_custom_fields = FieldVisibility.get_visible_custom_fields(final_selection)
socket
|> assign(:user_field_selection, final_selection)
|> assign(:member_fields_visible, visible_member_fields)
|> assign(
:member_fields_visible_db,
FieldVisibility.get_visible_member_fields_db(final_selection)
)
|> assign(
:member_fields_visible_computed,
FieldVisibility.get_visible_member_fields_computed(final_selection)
)
|> assign(:visible_custom_field_ids, extract_custom_field_ids(visible_custom_fields))
end
defp build_query_params(opts) when is_map(opts) do
@ -904,8 +924,14 @@ defmodule MvWeb.MemberLive.Index do
end
defp compute_final_field_selection(true, url_selection, socket) do
{cm, ie, ca} = view_flags(socket)
only_url =
FieldVisibility.selection_from_url_only(url_selection, socket.assigns.all_custom_fields)
FieldVisibility.selection_from_url_only(url_selection, socket.assigns.all_custom_fields,
compact_member: cm,
member_include_email: ie,
compact_address: ca
)
visible_members = FieldVisibility.get_visible_member_fields(only_url)
visible_custom = FieldVisibility.get_visible_custom_fields(only_url)
@ -919,6 +945,8 @@ defmodule MvWeb.MemberLive.Index do
end
defp compute_final_field_selection(false, url_selection, socket) do
{cm, ie, ca} = view_flags(socket)
merged =
FieldSelection.merge_sources(
url_selection,
@ -929,7 +957,10 @@ defmodule MvWeb.MemberLive.Index do
FieldVisibility.merge_with_global_settings(
merged,
socket.assigns.settings,
socket.assigns.all_custom_fields
socket.assigns.all_custom_fields,
compact_member: cm,
member_include_email: ie,
compact_address: ca
)
end
@ -941,6 +972,181 @@ defmodule MvWeb.MemberLive.Index do
end
end
# The connect-info Plug.Conn (present on the initial dead render), used to read
# per-browser cookies such as the persisted view settings.
defp connect_conn(socket) do
case socket.private[:connect_info] do
%Plug.Conn{} = conn -> conn
_ -> nil
end
end
# The LiveView connect params (present only on the connected mount). The client
# echoes the persisted view-settings cookie here because the connect-info map
# of a live socket does not expose cookies.
defp connect_params(socket) do
if connected?(socket), do: get_connect_params(socket), else: nil
end
# Assigns the full view-settings map plus the derived per-setting assigns the
# template renders from.
defp assign_view_settings(socket, settings) do
socket
|> assign(:view_settings, settings)
|> assign(:density, settings.density)
|> assign(:compact_member, settings.compact_member)
|> assign(:member_include_email, settings.member_include_email)
|> assign(:compact_address, settings.compact_address)
end
# Updates a single view setting, re-derives the template assigns and persists
# the whole settings map per browser (client writes the cookie; the connected
# mount reads it back via connect params).
defp update_view_setting(socket, key, value) do
settings = Map.put(socket.assigns.view_settings, key, value)
socket
|> assign_view_settings(settings)
|> push_event("store-view-settings", %{view_settings: ViewSettings.to_json(settings)})
end
# Flips the density setting. This only re-renders the table wrapper attribute
# (row spacing token), so the streamed rows do not need to be re-rendered.
defp toggle_view_setting(socket, :density) do
new = if socket.assigns.density == :compact, do: :comfortable, else: :compact
update_view_setting(socket, :density, new)
end
# Flips the "include email" sub-toggle. Only meaningful while the composite
# Member cell is on: it moves the email between an in-cell line (on) and a
# separate E-Mail column (off), so besides re-rendering the rows it recomputes
# which columns the manager offers and their visibility.
defp toggle_view_setting(socket, :member_include_email) do
new_value = not socket.assigns.view_settings.member_include_email
socket
|> update_view_setting(:member_include_email, new_value)
|> apply_include_email_toggle(new_value)
|> load_members()
|> prepare_dynamic_cols()
|> update_selection_assigns()
|> push_field_selection_url()
end
# Flips the compact Member field. Besides re-rendering the rows, this changes
# which columns the manager offers (composite "Name" vs. Vorname/Nachname/E-Mail,
# §7b). The email visibility carries over between the two representations,
# analogously to the "include email" sub-toggle: composite + include-email ⇄ the
# E-Mail column being visible.
defp toggle_view_setting(socket, :compact_member) do
new_value = not socket.assigns.view_settings.compact_member
email_visible? = email_currently_visible?(socket)
socket
|> update_view_setting(:compact_member, new_value)
|> sync_include_email(new_value, email_visible?)
|> apply_member_toggle(new_value, email_visible?)
|> load_members()
|> prepare_dynamic_cols()
|> update_selection_assigns()
|> push_field_selection_url()
end
# Flips the compact Address field: offers the composite "Adresse" vs. the
# separate Straße/Hausnummer/PLZ/Ort columns, making the newly relevant columns
# visible and recomputing the offered set + visibility for the new mode.
defp toggle_view_setting(socket, :compact_address) do
new_value = not socket.assigns.view_settings.compact_address
fields = if new_value, do: [:address], else: [:street, :house_number, :postal_code, :city]
socket
|> update_view_setting(:compact_address, new_value)
|> apply_composite_toggle(fields)
|> load_members()
|> prepare_dynamic_cols()
|> update_selection_assigns()
|> push_field_selection_url()
end
# Whether the member email is currently surfaced: while the composite is on,
# either folded into the cell (include_email) or as a separate E-Mail column;
# while it is off, as its own column. Read before the flip, so `compact_member`
# still holds the previous mode.
defp email_currently_visible?(socket) do
if socket.assigns.compact_member do
socket.assigns.member_include_email or :email in socket.assigns.member_fields_visible
else
:email in socket.assigns.member_fields_visible
end
end
# Turning include_email OFF surfaces the email as a separate column; make it
# visible. Turning it ON folds the email into the cell, so the column is no
# longer offered (the recompute drops it).
defp apply_include_email_toggle(socket, false = _include_email) do
selection = Map.put(socket.assigns.user_field_selection, "email", true)
recompute_offered(socket, selection)
end
defp apply_include_email_toggle(socket, true = _include_email),
do: recompute_offered(socket, socket.assigns.user_field_selection)
# When switching to the composite, mirror the email state onto the include-email
# sub-toggle so the email keeps being surfaced; when switching away, the
# sub-toggle is not applicable.
defp sync_include_email(socket, true = _new_compact, email_visible?),
do: update_view_setting(socket, :member_include_email, email_visible?)
defp sync_include_email(socket, false = _new_compact, _email_visible?), do: socket
# Makes the now-relevant Member columns visible for the new mode. Turning the
# composite on surfaces "Name" (with the email either folded via include_email
# or kept as a separate column); turning it off surfaces Vorname/Nachname and
# carries the prior email visibility onto the E-Mail column.
defp apply_member_toggle(socket, true = _new_compact, email_visible?) do
selection =
socket.assigns.user_field_selection
|> Map.put("name", true)
|> Map.put("email", email_visible?)
recompute_offered(socket, selection)
end
defp apply_member_toggle(socket, false = _new_compact, email_visible?) do
selection =
socket.assigns.user_field_selection
|> Map.put("first_name", true)
|> Map.put("last_name", true)
|> Map.put("email", email_visible?)
recompute_offered(socket, selection)
end
# Sets the given columns visible, then recomputes the offered set + visibility.
defp apply_composite_toggle(socket, fields) do
selection =
Enum.reduce(fields, socket.assigns.user_field_selection, fn field, acc ->
Map.put(acc, Atom.to_string(field), true)
end)
recompute_offered(socket, selection)
end
# Recomputes the offered column list and the derived visibility for the current
# mode. `update_view_setting` has already updated the compact_* assigns, so
# `view_flags/1` reflects the new mode here.
defp recompute_offered(socket, selection) do
{cm, ie, ca} = view_flags(socket)
socket
|> assign(
:all_available_fields,
FieldVisibility.get_offered_fields(socket.assigns.all_custom_fields, cm, ie, ca)
)
|> assign_field_visibility(selection)
end
# Parses optional "highlight" URL param (member id for selected row styling). Returns nil if missing or invalid.
defp parse_highlight_param(nil), do: nil
defp parse_highlight_param(""), do: nil