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
|
|
@ -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
|
||||
|
||||
|
|
|
|||
186
lib/mv_web/live/member_live/index/view_settings.ex
Normal file
186
lib/mv_web/live/member_live/index/view_settings.ex
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue