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:
parent
af2cc2e0d4
commit
dfc616257d
21 changed files with 1643 additions and 263 deletions
|
|
@ -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 doesn’t 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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue