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