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.
536 lines
20 KiB
Elixir
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
|