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:
Simon 2026-07-06 10:45:53 +02:00
parent af2cc2e0d4
commit dfc616257d
21 changed files with 1643 additions and 263 deletions

View file

@ -201,6 +201,56 @@ defmodule MvWeb.MemberLive.Index.FieldVisibilityTest do
end
end
describe "offered_member_fields/3 ordering (name group, then email, then address group, then rest)" do
test "compact member + compact address: Name, E-Mail, Adresse lead" do
result = FieldVisibility.offered_member_fields(true, false, true)
assert Enum.take(result, 3) == [:name, :email, :address]
# the composite constituents are not offered while their composite is on
refute :first_name in result
refute :street in result
# the Datenfelder-ordered rest trails the identity block
assert index_of(result, :address) < index_of(result, :join_date)
end
test "compact member with email folded in: no separate E-Mail column" do
result = FieldVisibility.offered_member_fields(true, true, true)
assert Enum.take(result, 2) == [:name, :address]
refute :email in result
end
test "non-compact member + compact address: Vorname/Nachname/E-Mail before Adresse" do
result = FieldVisibility.offered_member_fields(false, false, true)
# the reported bug: the composite address must not jump ahead of the
# expanded name constituents
assert Enum.take(result, 4) == [:first_name, :last_name, :email, :address]
assert index_of(result, :address) < index_of(result, :join_date)
end
test "non-compact member + non-compact address: name, email, then the address block, then rest" do
result = FieldVisibility.offered_member_fields(false, false, false)
assert Enum.take(result, 7) == [
:first_name,
:last_name,
:email,
:street,
:house_number,
:postal_code,
:city
]
# join_date and later Datenfelder columns never precede the address block
assert index_of(result, :city) < index_of(result, :join_date)
refute :name in result
refute :address in result
end
end
defp index_of(list, elem), do: Enum.find_index(list, &(&1 == elem))
describe "get_visible_fields/1" do
test "returns only fields with true visibility" do
selection = %{

View file

@ -0,0 +1,109 @@
defmodule MvWeb.MemberLive.Index.ViewSettingsTest do
@moduledoc """
§1.20 / §1.5 the member-overview view settings (density + the compact
member/address field toggles) and their per-browser persistence chain
(connect params > session > cookie > global default).
"""
use ExUnit.Case, async: true
alias MvWeb.MemberLive.Index.ViewSettings
test "defaults are all-compact with the email line off" do
assert ViewSettings.defaults() == %{
density: :compact,
compact_member: true,
member_include_email: false,
compact_address: true
}
end
describe "parse/1" do
test "reads a partial map with string keys and string/bool values" do
assert ViewSettings.parse(%{
"density" => "comfortable",
"compact_member" => false,
"member_include_email" => true
}) == %{
density: :comfortable,
compact_member: false,
member_include_email: true
}
end
test "ignores unknown keys and invalid values" do
assert ViewSettings.parse(%{"density" => "huge", "bogus" => true}) == %{}
end
test "returns empty map for non-maps" do
assert ViewSettings.parse(nil) == %{}
assert ViewSettings.parse("x") == %{}
end
end
describe "get_from_* sources" do
test "session JSON string" do
session = %{"member_view_settings" => ~s({"density":"comfortable"})}
assert ViewSettings.get_from_session(session) == %{density: :comfortable}
assert ViewSettings.get_from_session(%{}) == %{}
end
test "cookie header" do
json = ~s({"compact_address":false})
conn = %Plug.Conn{req_headers: [{"cookie", "member_view_settings=#{URI.encode(json)}"}]}
assert ViewSettings.get_from_cookie(conn) == %{compact_address: false}
assert ViewSettings.get_from_cookie(%Plug.Conn{req_headers: []}) == %{}
assert ViewSettings.get_from_cookie(nil) == %{}
end
test "connect params (raw JSON string under view_settings)" do
params = %{"view_settings" => ~s({"density":"comfortable","compact_member":false})}
assert ViewSettings.get_from_connect_params(params) == %{
density: :comfortable,
compact_member: false
}
assert ViewSettings.get_from_connect_params(nil) == %{}
end
end
describe "resolve/3 priority connect_params > session > cookie > default" do
test "falls back to defaults when nothing is stored" do
assert ViewSettings.resolve(%{}, nil, nil) == ViewSettings.defaults()
end
test "cookie overrides default, session overrides cookie, connect params win" do
cookie_json = ~s({"density":"comfortable","compact_member":false,"compact_address":false})
conn = %Plug.Conn{
req_headers: [{"cookie", "member_view_settings=#{URI.encode(cookie_json)}"}]
}
session = %{"member_view_settings" => ~s({"compact_member":true})}
connect_params = %{"view_settings" => ~s({"density":"compact"})}
assert ViewSettings.resolve(session, conn, connect_params) == %{
# from connect params (highest)
density: :compact,
# from session (overrides cookie)
compact_member: true,
# from cookie
compact_address: false,
# default
member_include_email: false
}
end
end
test "to_json/1 round-trips through parse/1" do
settings = %{
density: :comfortable,
compact_member: false,
member_include_email: true,
compact_address: false
}
assert settings |> ViewSettings.to_json() |> Jason.decode!() |> ViewSettings.parse() ==
settings
end
end