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

@ -31,7 +31,21 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do
# Groups and membership_fee_type are also pseudo fields (not in member_fields(), displayed in the table).
# :name and :address are composite display-only columns (Name = name + email,
# Address = street/house number over postal code/city).
@pseudo_member_fields [:membership_fee_status, :membership_fee_type, :groups, :name, :address]
# Order mirrors the overview table / export column order (fee type before fee
# status), so the Columns dropdown and the table agree.
@pseudo_member_fields [:membership_fee_type, :membership_fee_status, :groups, :name, :address]
# The composite display columns and the constituent columns they replace. The
# column manager offers exactly one variant per group, selected by the view
# settings: the composite when the matching "compact" setting is on, otherwise
# the constituents (see `offered_member_fields/2`).
@name_composite :name
@name_constituents [:first_name, :last_name, :email]
@address_composite :address
@address_constituents [:street, :house_number, :postal_code, :city]
# Export/API may accept this as alias; must not appear in the UI options list.
@export_only_alias :payment_status
# Curated default-visible column set (§1.2): the columns shown on first visit
# when there is no persisted selection and no global override. Everything else
@ -52,8 +66,139 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do
@spec default_visible_fields() :: [atom()]
def default_visible_fields, do: MapSet.to_list(@default_visible_fields)
# Export/API may accept this as alias; must not appear in the UI options list.
@export_only_alias :payment_status
@doc """
The curated default-visible columns for the given view settings.
In compact mode the composite `:name` / `:address` columns are visible by
default; when a composite is switched off, its constituent columns take over
as the defaults instead (§7b).
While the Member composite is compact, `include_email` decides where the email
lives: folded into the Member cell (`true`, no separate column), or as a
default-visible `:email` column (`false`).
"""
@spec default_visible_fields(boolean(), boolean(), boolean()) :: [atom()]
def default_visible_fields(compact_member, include_email, compact_address) do
compact_member
|> default_visible_set(include_email, compact_address)
|> MapSet.to_list()
end
defp default_visible_set(compact_member, include_email, compact_address) do
@default_visible_fields
|> apply_name_default(compact_member, include_email)
|> apply_group_default(compact_address, @address_composite, @address_constituents)
end
# Compact Member cell with email folded in: only the composite is default.
defp apply_name_default(set, true = _compact, true = _include_email), do: set
# Compact Member cell without folded email: the composite plus a separate
# default-visible E-Mail column.
defp apply_name_default(set, true = _compact, false = _include_email),
do: MapSet.put(set, :email)
# Non-compact: drop the composite and make first/last name and email default.
defp apply_name_default(set, false = _compact, _include_email) do
set
|> MapSet.delete(@name_composite)
|> MapSet.union(MapSet.new(@name_constituents))
end
# Compact: the composite stays the default. Non-compact: drop the composite and
# make the constituents default-visible.
defp apply_group_default(set, true, _composite, _constituents), do: set
defp apply_group_default(set, false, composite, constituents) do
set
|> MapSet.delete(composite)
|> MapSet.union(MapSet.new(constituents))
end
@doc """
Member-field atoms the column manager offers for the given view settings, in
dropdown/table order.
The identity block always leads, regardless of whether each group is compact:
1. the name group the composite `:name` when compact, otherwise its
constituents `:first_name`, `:last_name`;
2. the `:email` column, when offered as a separate column (it is folded into
the Member cell only while the composite is compact and `include_email` is
on);
3. the address group the composite `:address` when compact, otherwise its
constituents `:street`, `:house_number`, `:postal_code`, `:city`.
Everything else follows in the configured Datenfelder order. This keeps the
name group ahead of the address group even when the name group is expanded
into `:first_name`/`:last_name` while the address stays compact.
"""
@spec offered_member_fields(boolean(), boolean(), boolean()) :: [atom()]
def offered_member_fields(compact_member, include_email, compact_address) do
name_group = if compact_member, do: [@name_composite], else: [:first_name, :last_name]
email_group = if email_offered?(compact_member, include_email), do: [:email], else: []
address_group =
if compact_address, do: [@address_composite], else: @address_constituents
leading = name_group ++ email_group ++ address_group
rest =
Enum.reject(overview_member_fields(), fn field ->
field == @export_only_alias or
hidden_variant?(field, compact_member, include_email, compact_address) or
field in leading
end)
leading ++ rest
end
# The email is a separate offered column except when it is folded into the
# compact Member cell (compact + include_email).
defp email_offered?(compact_member, include_email), do: not (compact_member and include_email)
# The variant of a composite group that is not offered in the current mode.
defp hidden_variant?(field, compact_member, include_email, compact_address) do
name_variant_hidden?(field, compact_member, include_email) or
address_variant_hidden?(field, compact_address)
end
# Compact hides Vorname/Nachname (folded into the composite). The email is
# hidden only when it is folded into the cell (compact + include_email); with
# include_email off it stays offered as a separate column. Non-compact hides the
# composite in favour of the constituents.
defp name_variant_hidden?(@name_composite, compact_member, _include_email),
do: not compact_member
defp name_variant_hidden?(:email, compact_member, include_email),
do: compact_member and include_email
defp name_variant_hidden?(field, compact_member, _include_email)
when field in [:first_name, :last_name],
do: compact_member
defp name_variant_hidden?(_field, _compact_member, _include_email), do: false
defp address_variant_hidden?(@address_composite, compact_address),
do: not compact_address
defp address_variant_hidden?(field, compact_address)
when field in [:street, :house_number, :postal_code, :city],
do: compact_address
defp address_variant_hidden?(_field, _compact_address), do: false
@doc """
All fields the column manager offers for the given view settings: the offered
member fields (see `offered_member_fields/3`) followed by the custom fields.
"""
@spec get_offered_fields([struct()], boolean(), boolean(), boolean()) :: [
atom() | String.t()
]
def get_offered_fields(custom_fields, compact_member, include_email, compact_address) do
custom_field_names = Enum.map(custom_fields, &"custom_field_#{&1.id}")
offered_member_fields(compact_member, include_email, compact_address) ++ custom_field_names
end
defp overview_member_fields do
Mv.Constants.member_fields() ++ @pseudo_member_fields
@ -93,8 +238,28 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do
String.t() => boolean()
}
def selection_from_url_only(url_selection, custom_fields) when is_map(url_selection) do
all_fields = get_all_available_fields(custom_fields)
do_selection_from_url_only(url_selection, get_all_available_fields(custom_fields))
end
def selection_from_url_only(_, _), do: %{}
@doc """
Like `selection_from_url_only/2`, but scoped to the columns offered for the
given view settings (`:compact_member` / `:member_include_email` /
`:compact_address` in `opts`).
"""
@spec selection_from_url_only(%{String.t() => boolean()}, [struct()], keyword()) :: %{
String.t() => boolean()
}
def selection_from_url_only(url_selection, custom_fields, opts)
when is_map(url_selection) and is_list(opts) do
{cm, ie, ca} = view_opts(opts)
do_selection_from_url_only(url_selection, get_offered_fields(custom_fields, cm, ie, ca))
end
def selection_from_url_only(_, _, _), do: %{}
defp do_selection_from_url_only(url_selection, all_fields) do
Enum.reduce(all_fields, %{}, fn field, acc ->
field_string = field_to_string(field)
visible = Map.get(url_selection, field_string, false)
@ -102,7 +267,13 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do
end)
end
def selection_from_url_only(_, _), do: %{}
defp view_opts(opts) do
{
Keyword.get(opts, :compact_member, true),
Keyword.get(opts, :member_include_email, false),
Keyword.get(opts, :compact_address, true)
}
end
@doc """
Merges user field selection with global settings.
@ -135,7 +306,31 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do
) :: %{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)
do_merge(user_selection, global_settings, custom_fields, all_fields, @default_visible_fields)
end
@doc """
Like `merge_with_global_settings/3`, but scoped to the columns offered for the
given view settings (`:compact_member` / `:member_include_email` /
`:compact_address` in `opts`).
Only offered columns appear in the result, so a column that is not applicable
in the current mode (e.g. `:first_name` while the composite "Name" is on) never
leaks into the visible set. Defaults follow the mode via
`default_visible_fields/2`.
"""
@spec merge_with_global_settings(%{String.t() => boolean()}, map(), [struct()], keyword()) ::
%{String.t() => boolean()}
def merge_with_global_settings(user_selection, global_settings, custom_fields, opts)
when is_list(opts) do
{cm, ie, ca} = view_opts(opts)
all_fields = get_offered_fields(custom_fields, cm, ie, ca)
default_set = default_visible_set(cm, ie, ca)
do_merge(user_selection, global_settings, custom_fields, all_fields, default_set)
end
defp do_merge(user_selection, global_settings, custom_fields, all_fields, default_set) do
global_visibility = get_global_visibility_map(global_settings, custom_fields, default_set)
Enum.reduce(all_fields, %{}, fn field, acc ->
field_string = field_to_string(field)
@ -280,15 +475,15 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do
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)
defp get_global_visibility_map(settings, custom_fields, default_set) do
member_visibility = get_member_field_visibility_from_settings(settings, default_set)
custom_field_visibility = get_custom_field_visibility(custom_fields)
Map.merge(member_visibility, custom_field_visibility)
end
# Gets member field visibility from settings (domain fields from settings, pseudo fields default true)
defp get_member_field_visibility_from_settings(settings) do
defp get_member_field_visibility_from_settings(settings, default_set) do
visibility_config =
VisibilityConfig.normalize(Map.get(settings, :member_field_visibility, %{}))
@ -297,13 +492,13 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do
domain_map =
Enum.reduce(domain_fields, %{}, fn field, acc ->
field_string = Atom.to_string(field)
default_visibility = MapSet.member?(@default_visible_fields, field)
default_visibility = MapSet.member?(default_set, field)
show_in_overview = Map.get(visibility_config, field, default_visibility)
Map.put(acc, field_string, show_in_overview)
end)
Enum.reduce(@pseudo_member_fields, domain_map, fn field, acc ->
Map.put(acc, Atom.to_string(field), MapSet.member?(@default_visible_fields, field))
Map.put(acc, Atom.to_string(field), MapSet.member?(default_set, field))
end)
end

View file

@ -0,0 +1,186 @@
defmodule MvWeb.MemberLive.Index.ViewSettings do
@moduledoc """
Resolves and persists the member-overview view settings per browser/device.
A view-settings value is a map of four keys:
* `:density` `:compact` or `:comfortable` (the row-spacing / compact mode)
* `:compact_member` composite "Member" cell (last+first name) vs separate
Vorname/Nachname columns
* `:member_include_email` whether the email is surfaced (email line in the
composite cell, or a separate Email column when the member field is split)
* `:compact_address` composite Address cell (street / postal+city) vs
separate Straße/PLZ/Ort columns
Persistence is per browser (not per account), resolved in priority order from
the LiveView connect params, the session, the request cookie, and finally the
global defaults (`defaults/0`). The chosen value is written to a long-lived
cookie by a small client-side listener; on the next connected mount the client
echoes the cookie back through the socket connect params (the connect-info map
on a live socket does not expose cookies), and on the disconnected render the
cookie is read directly from the request conn.
"""
@cookie_name "member_view_settings"
@session_key "member_view_settings"
@connect_param "view_settings"
@cookie_max_age 365 * 24 * 60 * 60
@defaults %{
density: :compact,
compact_member: true,
member_include_email: false,
compact_address: true
}
@densities [:compact, :comfortable]
@bool_keys [:compact_member, :member_include_email, :compact_address]
@type t :: %{
density: :compact | :comfortable,
compact_member: boolean(),
member_include_email: boolean(),
compact_address: boolean()
}
@doc "The global default view settings used when nothing is stored."
@spec defaults() :: %{
density: :compact,
compact_member: true,
member_include_email: false,
compact_address: true
}
def defaults, do: @defaults
@doc "The cookie name the settings are persisted under (used by the client listener)."
@spec cookie_name() :: String.t()
def cookie_name, do: @cookie_name
@doc "The cookie max-age in seconds (365 days)."
@spec cookie_max_age() :: 31_536_000
def cookie_max_age, do: @cookie_max_age
@doc """
Parses a raw map (string or atom keys, string/boolean values) into a validated
partial settings map with atom keys. Unknown keys and invalid values are
dropped, so a caller can safely `Map.merge/2` the result over another source.
"""
@spec parse(term()) :: %{optional(atom()) => term()}
def parse(raw) when is_map(raw) do
Enum.reduce(raw, %{}, fn {key, value}, acc ->
case parse_pair(to_string(key), value) do
{k, v} -> Map.put(acc, k, v)
:error -> acc
end
end)
end
def parse(_), do: %{}
defp parse_pair("density", value) do
case parse_density(value) do
nil -> :error
density -> {:density, density}
end
end
defp parse_pair(key, value)
when key in ~w(compact_member member_include_email compact_address) do
case parse_bool(value) do
nil -> :error
bool -> {String.to_existing_atom(key), bool}
end
end
defp parse_pair(_key, _value), do: :error
defp parse_density(value) when value in @densities, do: value
defp parse_density("compact"), do: :compact
defp parse_density("comfortable"), do: :comfortable
defp parse_density(_), do: nil
defp parse_bool(value) when is_boolean(value), do: value
defp parse_bool("true"), do: true
defp parse_bool("false"), do: false
defp parse_bool(_), do: nil
@doc "Reads partial settings from the LiveView session map."
@spec get_from_session(map()) :: %{optional(atom()) => term()}
def get_from_session(session) when is_map(session),
do: parse_json(Map.get(session, @session_key))
def get_from_session(_), do: %{}
@doc "Reads partial settings from the request cookie header of a connect-info conn."
@spec get_from_cookie(Plug.Conn.t() | nil) :: %{optional(atom()) => term()}
def get_from_cookie(%Plug.Conn{} = conn) do
case Plug.Conn.get_req_header(conn, "cookie") do
[cookie_header | _rest] ->
cookie_header
|> parse_cookie_header()
|> Map.get(@cookie_name)
|> parse_json()
_ ->
%{}
end
end
def get_from_cookie(_), do: %{}
@doc "Reads partial settings from the LiveView connect params (raw JSON string)."
@spec get_from_connect_params(map() | nil) :: %{optional(atom()) => term()}
def get_from_connect_params(params) when is_map(params),
do: parse_json(Map.get(params, @connect_param))
def get_from_connect_params(_), do: %{}
@doc """
Resolves the effective view settings for a mount, merging the sources by
priority (connect params > session > cookie), falling back per key to the
global defaults.
"""
@spec resolve(map(), Plug.Conn.t() | nil, map() | nil) :: t()
def resolve(session, conn, connect_params \\ nil) do
@defaults
|> Map.merge(get_from_cookie(conn))
|> Map.merge(get_from_session(session))
|> Map.merge(get_from_connect_params(connect_params))
end
@doc "Serializes settings to a JSON string for the client-side cookie writer."
@spec to_json(map()) :: String.t()
def to_json(settings) when is_map(settings) do
settings
|> Map.take([:density | @bool_keys])
|> Map.new(fn
{:density, density} -> {:density, to_string(density)}
{key, value} -> {key, value}
end)
|> Jason.encode!()
end
defp parse_json(nil), do: %{}
defp parse_json(json) when is_binary(json) do
case Jason.decode(json) do
{:ok, decoded} -> parse(decoded)
_ -> %{}
end
end
defp parse_json(_), do: %{}
# Parses a cookie header string into a name => value 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
end