mitgliederverwaltung/lib/mv_web/live/member_live/index/view_settings.ex
Simon 77fc11a0b0 refactor(member-live): modularize the overview and drop the superseded in-memory paths
Extract the cookie parser, export-payload builder and fee-status helper
into their own modules and remove the filter/sort/load helpers the
database pushdown made dead, so the overview LiveView stays a thin
coordinator over focused units.
2026-07-06 11:00:29 +02:00

175 lines
5.7 KiB
Elixir

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.
"""
alias MvWeb.MemberLive.Index.Cookie
@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
|> Cookie.parse_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: %{}
end