mitgliederverwaltung/lib/mv_web/live/member_live/index/field_visibility.ex
Simon dfc616257d 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.
2026-07-06 10:45:53 +02:00

536 lines
20 KiB
Elixir

defmodule MvWeb.MemberLive.Index.FieldVisibility do
@moduledoc """
Manages field visibility by merging user-specific selection with global settings.
This module handles:
- Getting all available fields (member fields + custom fields)
- Merging user selection with global settings (user selection takes priority)
- Falling back to global settings when no user selection exists
- Converting between different field name formats (atoms vs strings)
## Field Naming Convention
- **Member Fields**: Atoms (e.g., `:first_name`, `:email`)
- **Custom Fields**: Strings with format `"custom_field_<id>"` (e.g., `"custom_field_abc-123"`)
## Priority Order
1. User-specific selection (from URL/Session/Cookie)
2. Global settings (from database)
3. Default (all fields visible)
## Pseudo Member Fields
Overview-only fields that are not in `Mv.Constants.member_fields()` (e.g. computed/UI-only).
They appear in the field dropdown and in `member_fields_visible` but are not domain attributes.
"""
alias Mv.Membership.Helpers.VisibilityConfig
# Single UI key for "Membership Fee Status"; only this appears in the dropdown.
# 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).
# 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
# (the individual name/address sub-fields, notes, dates other than join date)
# defaults to hidden so the table fits common desktop widths.
@default_visible_fields MapSet.new([
:name,
:address,
:membership_fee_type,
:membership_fee_status,
:groups,
:join_date
])
@doc """
The curated default-visible columns (member-field atoms) for a first visit.
"""
@spec default_visible_fields() :: [atom()]
def default_visible_fields, do: MapSet.to_list(@default_visible_fields)
@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
end
@doc """
Gets all available fields for selection.
Returns a list of field identifiers:
- Member fields as atoms (e.g., `:first_name`, `:email`)
- Custom fields as strings (e.g., `"custom_field_abc-123"`)
## Parameters
- `custom_fields` - List of CustomField resources that are available
## Returns
List of field identifiers (atoms and strings)
"""
@spec get_all_available_fields([struct()]) :: [atom() | String.t()]
def get_all_available_fields(custom_fields) do
member_fields =
overview_member_fields()
|> Enum.reject(fn field -> field == @export_only_alias end)
custom_field_names = Enum.map(custom_fields, &"custom_field_#{&1.id}")
member_fields ++ custom_field_names
end
@doc """
Builds field selection from URL only: fields in `url_selection` are visible, all others false.
Use when `?fields=...` is in the URL so column visibility is not merged with global settings.
"""
@spec selection_from_url_only(%{String.t() => boolean()}, [struct()]) :: %{
String.t() => boolean()
}
def selection_from_url_only(url_selection, custom_fields) when is_map(url_selection) do
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)
Map.put(acc, field_string, visible)
end)
end
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.
User selection takes priority over global settings. If a field is not in the
user selection, the global setting is used. If a field is not in global settings,
it defaults to `true` (visible).
## Parameters
- `user_selection` - Map of field names (strings) to boolean visibility
- `global_settings` - Settings struct with `member_field_visibility` field
- `custom_fields` - List of CustomField resources
## Returns
Map of field names (strings) to boolean visibility values
## Examples
iex> user_selection = %{"first_name" => false}
iex> settings = %{member_field_visibility: %{first_name: true, email: true}}
iex> merge_with_global_settings(user_selection, settings, [])
%{"first_name" => false, "email" => true} # User selection overrides global
"""
@spec merge_with_global_settings(
%{String.t() => boolean()},
map(),
[struct()]
) :: %{String.t() => boolean()}
def merge_with_global_settings(user_selection, global_settings, custom_fields) do
all_fields = get_all_available_fields(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)
visibility =
case Map.get(user_selection, field_string) do
nil -> Map.get(global_visibility, field_string, true)
user_value -> user_value
end
Map.put(acc, field_string, visibility)
end)
end
@doc """
Gets the list of visible fields from a field selection map.
Returns only fields where visibility is `true`.
## Parameters
- `field_selection` - Map of field names to boolean visibility
## Returns
List of field identifiers (atoms for member fields, strings for custom fields)
## Examples
iex> selection = %{"first_name" => true, "email" => false, "street" => true}
iex> get_visible_fields(selection)
[:first_name, :street]
"""
@spec get_visible_fields(%{String.t() => boolean()}) :: [atom() | String.t()]
def get_visible_fields(field_selection) when is_map(field_selection) do
field_selection
|> Enum.filter(fn {_field, visible} -> visible end)
|> Enum.map(fn {field_string, _visible} -> to_field_identifier(field_string) end)
|> Enum.uniq()
end
def get_visible_fields(_), do: []
@doc """
Gets visible member fields from field selection.
Returns only member fields (atoms) that are visible.
## Examples
iex> selection = %{"first_name" => true, "email" => true, "custom_field_123" => true}
iex> get_visible_member_fields(selection)
[:first_name, :email]
"""
@spec get_visible_member_fields(%{String.t() => boolean()}) :: [atom()]
def get_visible_member_fields(field_selection) when is_map(field_selection) do
member_fields = overview_member_fields()
field_selection
|> Enum.filter(fn {field_string, visible} ->
field_atom = to_field_identifier(field_string)
visible && field_atom in member_fields
end)
|> Enum.map(fn {field_string, _visible} -> to_field_identifier(field_string) end)
|> Enum.uniq()
end
def get_visible_member_fields(_), do: []
@doc """
Returns the list of computed (UI-only) member field atoms.
These fields are not in the database; they must not be used for Ash query
select/sort. Use this to filter sort options and validate sort_field.
"""
@spec computed_member_fields() :: [:membership_fee_status | :membership_fee_type | :groups, ...]
def computed_member_fields, do: @pseudo_member_fields
@doc """
Visible member fields that are real DB attributes (from `Mv.Constants.member_fields()`).
Use for query select/sort. Not for rendering column visibility (use
`get_visible_member_fields/1` for that).
"""
@spec get_visible_member_fields_db(%{String.t() => boolean()}) :: [atom()]
def get_visible_member_fields_db(field_selection) when is_map(field_selection) do
db_fields = MapSet.new(Mv.Constants.member_fields())
field_selection
|> Enum.filter(fn {field_string, visible} ->
field_atom = to_field_identifier(field_string)
visible && field_atom in db_fields
end)
|> Enum.map(fn {field_string, _visible} -> to_field_identifier(field_string) end)
|> Enum.uniq()
end
def get_visible_member_fields_db(_), do: []
@doc """
Visible member fields that are computed/UI-only (e.g. membership_fee_status).
Use for rendering; do not use for query select or sort.
"""
@spec get_visible_member_fields_computed(%{String.t() => boolean()}) :: [atom()]
def get_visible_member_fields_computed(field_selection) when is_map(field_selection) do
computed_set = MapSet.new([:membership_fee_status])
field_selection
|> Enum.filter(fn {field_string, visible} ->
field_atom = to_field_identifier(field_string)
visible && field_atom in computed_set
end)
|> Enum.map(fn {field_string, _visible} -> to_field_identifier(field_string) end)
|> Enum.uniq()
end
def get_visible_member_fields_computed(_), do: []
@doc """
Gets visible custom fields from field selection.
Returns only custom field identifiers (strings) that are visible.
## Examples
iex> selection = %{"first_name" => true, "custom_field_123" => true, "custom_field_456" => false}
iex> get_visible_custom_fields(selection)
["custom_field_123"]
"""
@spec get_visible_custom_fields(%{String.t() => boolean()}) :: [String.t()]
def get_visible_custom_fields(field_selection) when is_map(field_selection) do
prefix = Mv.Constants.custom_field_prefix()
field_selection
|> Enum.filter(fn {field_string, visible} ->
visible && String.starts_with?(field_string, prefix)
end)
|> Enum.map(fn {field_string, _visible} -> field_string end)
end
def get_visible_custom_fields(_), do: []
# Gets global visibility map from 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, default_set) do
visibility_config =
VisibilityConfig.normalize(Map.get(settings, :member_field_visibility, %{}))
domain_fields = Mv.Constants.member_fields()
domain_map =
Enum.reduce(domain_fields, %{}, fn field, acc ->
field_string = Atom.to_string(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_set, field))
end)
end
# Gets custom field visibility (all custom fields with show_in_overview=true are visible)
defp get_custom_field_visibility(custom_fields) do
prefix = Mv.Constants.custom_field_prefix()
Enum.reduce(custom_fields, %{}, fn custom_field, acc ->
field_string = "#{prefix}#{custom_field.id}"
visible = Map.get(custom_field, :show_in_overview, true)
Map.put(acc, field_string, visible)
end)
end
# Converts field string to atom (for member fields) or keeps as string (for custom fields).
# Maps export-only alias to canonical UI key so only one option controls the column.
defp to_field_identifier(field_string) when is_binary(field_string) do
if String.starts_with?(field_string, Mv.Constants.custom_field_prefix()) do
field_string
else
atom =
try do
String.to_existing_atom(field_string)
rescue
ArgumentError -> field_string
end
if atom == @export_only_alias, do: :membership_fee_status, else: atom
end
end
# Converts field identifier 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
end