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.
186 lines
6.1 KiB
Elixir
186 lines
6.1 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.
|
|
"""
|
|
|
|
@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
|