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
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