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

@ -414,15 +414,46 @@ Hooks.LoadMorePrefetch = {
}
}
// Reads a browser cookie value by name (used to echo the persisted member
// view-settings to the server on connect, since the live socket's connect-info
// does not expose cookies).
function getCookie(name) {
const match = document.cookie.match(new RegExp("(?:^|; )" + name + "=([^;]*)"))
return match ? decodeURIComponent(match[1]) : null
}
let liveSocket = new LiveSocket("/live", Socket, {
longPollFallbackMs: 2500,
params: {
_csrf_token: csrfToken,
timezone: getBrowserTimezone()
timezone: getBrowserTimezone(),
view_settings: getCookie("member_view_settings")
},
hooks: Hooks
})
// Persist the member-overview view settings per browser/device (§1.5/§1.20). The
// LiveView pushes "store-view-settings" (density + compact field toggles) on
// change; we write a long-lived cookie that the LiveView reads back on the next
// full page load (via the request cookie on the dead render and via connect
// params on the connected mount).
// Return the members list to the top after a sort or filter change. The server
// resets the keyset stream to page 1 on those changes and pushes this event; a
// user scrolled down would otherwise be stranded past the shorter content with
// infinite scroll not re-arming. No-op if the scroll container is absent.
window.addEventListener("phx:members:scroll-top", () => {
const el = document.getElementById("members-table-guard")
if (el) el.scrollTop = 0
})
window.addEventListener("phx:store-view-settings", (e) => {
const json = e.detail && e.detail.view_settings
if (typeof json === "string" && json.length > 0) {
const maxAge = 365 * 24 * 60 * 60
document.cookie = `member_view_settings=${encodeURIComponent(json)};path=/;max-age=${maxAge};samesite=lax`
}
})
// Listen for custom events from LiveView
window.addEventListener("phx:set-input-value", (e) => {
const {id, value} = e.detail
@ -432,15 +463,6 @@ window.addEventListener("phx:set-input-value", (e) => {
}
})
// Return the members list to the top after a sort or filter change. The server
// resets the keyset stream to page 1 on those changes and pushes this event; a
// user scrolled down would otherwise be stranded past the shorter content with
// infinite scroll not re-arming. No-op if the scroll container is absent.
window.addEventListener("phx:members:scroll-top", () => {
const el = document.getElementById("members-table-guard")
if (el) el.scrollTop = 0
})
// Show progress bar on live navigation and form submits
topbar.config({barColors: {0: "#29d"}, shadowColor: "rgba(0, 0, 0, .3)"})
window.addEventListener("phx:page-loading-start", _info => topbar.show(300))

View file

@ -250,7 +250,7 @@ defmodule MvWeb.CoreComponents do
attr :size, :any,
default: "md",
doc: "Badge size: sm | md"
doc: "Badge size: xs | sm | md"
attr :sr_label, :string,
default: nil,
@ -269,7 +269,7 @@ defmodule MvWeb.CoreComponents do
variant_class = "badge-#{variant}"
style_class = badge_style_class(style)
size_class = "badge-#{size}"
size_class = badge_size_class(size)
# Outline has transparent bg in DaisyUI; add bg so it stays visible on base-200/base-300
outline_bg = if style == "outline", do: "bg-base-100", else: nil
@ -309,6 +309,13 @@ defmodule MvWeb.CoreComponents do
defp badge_style_class("outline"), do: "badge-outline"
defp badge_style_class(_), do: nil
# Literal strings so Tailwind's content scanner sees badge-xs / badge-sm / badge-md / badge-lg
# and does not purge them from the CSS bundle (string interpolation "badge-#{size}" would be invisible).
defp badge_size_class("xs"), do: "badge-xs"
defp badge_size_class("sm"), do: "badge-sm"
defp badge_size_class("lg"), do: "badge-lg"
defp badge_size_class(_), do: "badge-md"
@doc """
Renders a visually empty table cell with screen-reader-only text (WCAG).
@ -443,6 +450,11 @@ defmodule MvWeb.CoreComponents do
attr :selected, :map, default: %{}
attr :open, :boolean, default: false, doc: "Whether the dropdown is open"
attr :show_select_buttons, :boolean, default: false, doc: "Show select all/none buttons"
attr :show_reset_button, :boolean,
default: false,
doc: "Show a reset-to-default icon button next to All/None (emits reset_fields)"
attr :phx_target, :any, required: true, doc: "The LiveView/LiveComponent target for events"
attr :menu_class, :string, default: nil, doc: "Additional CSS classes for the menu"
attr :menu_width, :string, default: "w-64", doc: "Width class for the menu (default: w-64)"
@ -546,6 +558,19 @@ defmodule MvWeb.CoreComponents do
>
{gettext("None")}
</button>
<button
:if={@show_reset_button}
type="button"
role="menuitem"
aria-label={gettext("Reset to default")}
title={gettext("Reset to default")}
phx-click="reset_fields"
phx-target={@phx_target}
class="btn btn-xs btn-ghost"
data-testid="dropdown-reset"
>
<.icon name="hero-arrow-uturn-left" class="size-4" />
</button>
</div>
</div>
</li>
@ -938,6 +963,11 @@ defmodule MvWeb.CoreComponents do
attr :sort_field, :any, default: nil, doc: "current sort field"
attr :sort_order, :atom, default: nil, doc: "current sort order"
attr :size_class, :string,
default: "",
doc:
"optional DaisyUI table-size class controlling row density (e.g. table-xs, table-md); driven by the view-settings density value"
attr :sticky_header, :boolean,
default: false,
doc:
@ -1016,7 +1046,7 @@ defmodule MvWeb.CoreComponents do
data-sticky-first-col-rows={@sticky_first_col && "true"}
phx-hook={@row_click && "TableRowKeydown"}
>
<table class="table table-zebra">
<table class={["table table-zebra", @size_class]}>
<thead>
<tr>
<th
@ -1223,7 +1253,7 @@ defmodule MvWeb.CoreComponents do
defp table_th_sticky_class(_), do: nil
defp sticky_th_classes,
do: "lg:sticky lg:top-0 bg-base-100 z-10 hover:z-20 focus-within:z-20"
do: "lg:sticky lg:top-0 bg-base-100 hover:bg-base-100 z-10 hover:z-20 focus-within:z-20"
@doc """
Renders a reorderable table (sortable list) with drag handle and keyboard support.

View file

@ -69,12 +69,13 @@ defmodule MvWeb.Components.FieldVisibilityDropdownComponent do
<.dropdown_menu
id="field-visibility-menu"
icon="hero-adjustments-horizontal"
button_label={gettext("Show/Hide Columns")}
button_label={gettext("Columns")}
items={@all_items}
checkboxes={true}
selected={@selected_fields}
open={@open}
show_select_buttons={true}
show_reset_button={true}
phx_target={@myself}
/>
</div>
@ -115,6 +116,13 @@ defmodule MvWeb.Components.FieldVisibilityDropdownComponent do
{:noreply, assign(socket, :selected_fields, all)}
end
# reset to the curated default column set (the parent owns the settings needed
# to compute the default, so it recomputes and re-applies the selection)
def handle_event("reset_fields", _params, socket) do
send(self(), {:fields_reset})
{:noreply, socket}
end
# select none
def handle_event("select_none", _params, socket) do
none =

View file

@ -32,11 +32,14 @@ defmodule MvWeb.Components.SortHeaderComponent do
>
{@label}
<%= if @sort_field == @field do %>
<.icon name={if @sort_order == :asc, do: "hero-chevron-up", else: "hero-chevron-down"} />
<.icon
name={if @sort_order == :asc, do: "hero-chevron-up", else: "hero-chevron-down"}
class="sort-icon"
/>
<% else %>
<.icon
name="hero-chevron-up-down"
class="opacity-40"
class="sort-icon opacity-40"
/>
<% end %>
</.button>

View file

@ -0,0 +1,129 @@
defmodule MvWeb.Components.ViewSettingsDropdownComponent do
@moduledoc """
LiveComponent for the member-overview view settings (§1.20).
Renders an icon button with a tooltip that opens a dropdown of toggles:
* Compact mode table row density (compact comfortable)
* Compact Member field composite last+first name cell, with a sub-toggle
"include email"
* Compact address field composite street / postal+city cell
Turning a composite field off surfaces the underlying separate columns in the
table. The component is display-only: it emits `{:view_setting_toggled, key}`
to the parent LiveView, which owns and persists the settings.
## Props
- `:density` `:compact` or `:comfortable`
- `:compact_member` boolean
- `:member_include_email` boolean
- `:compact_address` boolean
- `:id` component id
"""
use MvWeb, :live_component
@impl true
def update(assigns, socket) do
{:ok,
socket
|> assign(assigns)
|> assign_new(:open, fn -> false end)}
end
@impl true
def render(assigns) do
~H"""
<div>
<.dropdown_menu
id="view-settings-menu"
icon="hero-cog-6-tooth"
button_label={gettext("View")}
open={@open}
phx_target={@myself}
menu_width="w-72"
testid="view-settings"
button_testid="view-settings-button"
>
<li role="none" class="mb-1 px-2">
<span class="font-semibold">{gettext("View settings")}</span>
</li>
<.toggle_item
setting="density"
label={gettext("Compact mode")}
checked={@density == :compact}
target={@myself}
/>
<.toggle_item
setting="compact_member"
label={gettext("Compact Member field")}
checked={@compact_member}
target={@myself}
/>
<%!-- The email line lives inside the composite Member cell, so this
sub-toggle is only meaningful while that cell is compact. When the
composite is off, email is its own column and this toggle is omitted. --%>
<li :if={@compact_member} role="none" class="pl-6">
<.toggle_item
setting="member_include_email"
label={gettext("include email")}
checked={@member_include_email}
target={@myself}
/>
</li>
<.toggle_item
setting="compact_address"
label={gettext("Compact address field")}
checked={@compact_address}
target={@myself}
/>
</.dropdown_menu>
</div>
"""
end
attr :setting, :string, required: true
attr :label, :string, required: true
attr :checked, :boolean, required: true
attr :target, :any, required: true
defp toggle_item(assigns) do
~H"""
<button
type="button"
role="menuitemcheckbox"
aria-checked={to_string(@checked)}
aria-label={@label}
tabindex="0"
class="flex items-center gap-2 px-2 py-1 rounded w-full text-left cursor-pointer hover:bg-base-200"
phx-click="toggle"
phx-value-setting={@setting}
phx-target={@target}
data-testid={"view-setting-#{String.replace(@setting, "_", "-")}"}
>
<span class={if @checked, do: "text-primary", else: "text-base-300"} aria-hidden="true">
<.icon name="hero-check" class="size-4 shrink-0" />
</span>
<span>{@label}</span>
</button>
"""
end
@impl true
def handle_event("toggle_dropdown", _params, socket) do
{:noreply, assign(socket, :open, !socket.assigns.open)}
end
def handle_event("close_dropdown", _params, socket) do
{:noreply, assign(socket, :open, false)}
end
def handle_event("toggle", %{"setting" => setting}, socket) do
send(self(), {:view_setting_toggled, String.to_existing_atom(setting)})
{:noreply, socket}
end
end

View file

@ -41,6 +41,7 @@ defmodule MvWeb.MemberLive.Index do
alias MvWeb.MemberLive.Index.Formatter
alias MvWeb.MemberLive.Index.MembershipFeeStatus
alias MvWeb.MemberLive.Index.OverviewQuery
alias MvWeb.MemberLive.Index.ViewSettings
require Ash.Query
require Logger
@ -123,16 +124,31 @@ defmodule MvWeb.MemberLive.Index do
# Load user field selection from session
session_selection = FieldSelection.get_from_session(session)
# FIX: ensure dropdown doesnt show duplicate fields (e.g. membership fee status twice)
# Resolve the per-browser view settings first: the columns the manager offers
# (composite vs. constituent Name/Address columns) follow these settings (§7b).
view_settings = ViewSettings.resolve(session, connect_conn(socket), connect_params(socket))
compact_member = view_settings.compact_member
member_include_email = view_settings.member_include_email
compact_address = view_settings.compact_address
# The dropdown offers exactly the columns applicable to the current view
# settings, so composite and constituent columns are never both listed.
all_available_fields =
all_custom_fields
|> FieldVisibility.get_all_available_fields()
FieldVisibility.get_offered_fields(
all_custom_fields,
compact_member,
member_include_email,
compact_address
)
initial_selection =
FieldVisibility.merge_with_global_settings(
session_selection,
settings,
all_custom_fields
all_custom_fields,
compact_member: compact_member,
member_include_email: member_include_email,
compact_address: compact_address
)
socket =
@ -183,6 +199,7 @@ defmodule MvWeb.MemberLive.Index do
|> assign(:mailto_bcc, "")
|> assign(:recipient_count, 0)
|> assign(:mailto_disabled?, false)
|> assign_view_settings(view_settings)
|> stream_configure(:members, dom_id: &"row-#{&1.id}")
|> stream(:members, [])
|> assign_export_payload()
@ -385,6 +402,11 @@ defmodule MvWeb.MemberLive.Index do
- `{:fields_selected, selection}` - Select all/deselect all event from FieldVisibilityDropdownComponent
"""
@impl true
def handle_info({:view_setting_toggled, key}, socket) do
{:noreply, toggle_view_setting(socket, key)}
end
@impl true
def handle_info({:search_changed, q}, socket) do
socket =
@ -604,34 +626,10 @@ defmodule MvWeb.MemberLive.Index do
@impl true
def handle_info({:field_toggled, field_string, visible}, socket) do
new_selection = Map.put(socket.assigns.user_field_selection, field_string, visible)
socket = update_session_field_selection(socket, new_selection)
final_selection =
FieldVisibility.merge_with_global_settings(
new_selection,
socket.assigns.settings,
socket.assigns.all_custom_fields
)
visible_member_fields =
final_selection
|> FieldVisibility.get_visible_member_fields()
|> Enum.uniq()
visible_member_fields_db = FieldVisibility.get_visible_member_fields_db(final_selection)
visible_member_fields_computed =
FieldVisibility.get_visible_member_fields_computed(final_selection)
visible_custom_fields = FieldVisibility.get_visible_custom_fields(final_selection)
socket =
socket
|> assign(:user_field_selection, final_selection)
|> assign(:member_fields_visible, visible_member_fields)
|> assign(:member_fields_visible_db, visible_member_fields_db)
|> assign(:member_fields_visible_computed, visible_member_fields_computed)
|> assign(:visible_custom_field_ids, extract_custom_field_ids(visible_custom_fields))
|> assign_field_visibility(new_selection)
|> load_members()
|> prepare_dynamic_cols()
|> update_selection_assigns()
@ -641,35 +639,29 @@ defmodule MvWeb.MemberLive.Index do
end
@impl true
def handle_info({:fields_selected, selection}, socket) do
socket = update_session_field_selection(socket, selection)
def handle_info({:fields_reset}, socket) do
# Reset restores the curated default column set (§1.6): recompute the default
# visibility from the global settings and re-apply it as the selection.
{cm, ie, ca} = view_flags(socket)
final_selection =
default_selection =
FieldVisibility.merge_with_global_settings(
selection,
%{},
socket.assigns.settings,
socket.assigns.all_custom_fields
socket.assigns.all_custom_fields,
compact_member: cm,
member_include_email: ie,
compact_address: ca
)
visible_member_fields =
final_selection
|> FieldVisibility.get_visible_member_fields()
|> Enum.uniq()
visible_member_fields_db = FieldVisibility.get_visible_member_fields_db(final_selection)
visible_member_fields_computed =
FieldVisibility.get_visible_member_fields_computed(final_selection)
visible_custom_fields = FieldVisibility.get_visible_custom_fields(final_selection)
handle_info({:fields_selected, default_selection}, socket)
end
@impl true
def handle_info({:fields_selected, selection}, socket) do
socket =
socket
|> assign(:user_field_selection, final_selection)
|> assign(:member_fields_visible, visible_member_fields)
|> assign(:member_fields_visible_db, visible_member_fields_db)
|> assign(:member_fields_visible_computed, visible_member_fields_computed)
|> assign(:visible_custom_field_ids, extract_custom_field_ids(visible_custom_fields))
|> assign_field_visibility(selection)
|> load_members()
|> prepare_dynamic_cols()
|> update_selection_assigns()
@ -697,18 +689,6 @@ defmodule MvWeb.MemberLive.Index do
url_selection = FieldSelection.parse_from_url(params)
final_selection = compute_final_field_selection(fields_in_url?, url_selection, socket)
visible_member_fields =
final_selection
|> FieldVisibility.get_visible_member_fields()
|> Enum.uniq()
visible_member_fields_db = FieldVisibility.get_visible_member_fields_db(final_selection)
visible_member_fields_computed =
FieldVisibility.get_visible_member_fields_computed(final_selection)
visible_custom_fields = FieldVisibility.get_visible_custom_fields(final_selection)
socket =
socket
|> maybe_update_search(params)
@ -721,11 +701,7 @@ defmodule MvWeb.MemberLive.Index do
|> maybe_update_show_current_cycle(params)
|> assign(:fields_in_url?, fields_in_url?)
|> assign(:query, params["query"])
|> assign(:user_field_selection, final_selection)
|> assign(:member_fields_visible, visible_member_fields)
|> assign(:member_fields_visible_db, visible_member_fields_db)
|> assign(:member_fields_visible_computed, visible_member_fields_computed)
|> assign(:visible_custom_field_ids, extract_custom_field_ids(visible_custom_fields))
|> assign_visibility_derivations(final_selection)
|> assign(:selected_member_id, parse_highlight_param(params["highlight"]))
next_sig = build_signature(socket)
@ -863,8 +839,52 @@ defmodule MvWeb.MemberLive.Index do
push_reload(socket, new_path)
end
defp update_session_field_selection(socket, selection) do
assign(socket, :user_field_selection, selection)
# The current composite view flags; the offered column set is scoped to these.
defp view_flags(socket) do
{socket.assigns.compact_member, socket.assigns.member_include_email,
socket.assigns.compact_address}
end
# Merges a raw selection with the global settings scoped to the current view
# settings, then assigns it together with the visibility-derived assigns.
defp assign_field_visibility(socket, selection) do
{cm, ie, ca} = view_flags(socket)
final =
FieldVisibility.merge_with_global_settings(
selection,
socket.assigns.settings,
socket.assigns.all_custom_fields,
compact_member: cm,
member_include_email: ie,
compact_address: ca
)
assign_visibility_derivations(socket, final)
end
# Assigns user_field_selection and every visibility-derived assign the table and
# export payload read from an already merged (offered-scoped) selection.
defp assign_visibility_derivations(socket, final_selection) do
visible_member_fields =
final_selection
|> FieldVisibility.get_visible_member_fields()
|> Enum.uniq()
visible_custom_fields = FieldVisibility.get_visible_custom_fields(final_selection)
socket
|> assign(:user_field_selection, final_selection)
|> assign(:member_fields_visible, visible_member_fields)
|> assign(
:member_fields_visible_db,
FieldVisibility.get_visible_member_fields_db(final_selection)
)
|> assign(
:member_fields_visible_computed,
FieldVisibility.get_visible_member_fields_computed(final_selection)
)
|> assign(:visible_custom_field_ids, extract_custom_field_ids(visible_custom_fields))
end
defp build_query_params(opts) when is_map(opts) do
@ -904,8 +924,14 @@ defmodule MvWeb.MemberLive.Index do
end
defp compute_final_field_selection(true, url_selection, socket) do
{cm, ie, ca} = view_flags(socket)
only_url =
FieldVisibility.selection_from_url_only(url_selection, socket.assigns.all_custom_fields)
FieldVisibility.selection_from_url_only(url_selection, socket.assigns.all_custom_fields,
compact_member: cm,
member_include_email: ie,
compact_address: ca
)
visible_members = FieldVisibility.get_visible_member_fields(only_url)
visible_custom = FieldVisibility.get_visible_custom_fields(only_url)
@ -919,6 +945,8 @@ defmodule MvWeb.MemberLive.Index do
end
defp compute_final_field_selection(false, url_selection, socket) do
{cm, ie, ca} = view_flags(socket)
merged =
FieldSelection.merge_sources(
url_selection,
@ -929,7 +957,10 @@ defmodule MvWeb.MemberLive.Index do
FieldVisibility.merge_with_global_settings(
merged,
socket.assigns.settings,
socket.assigns.all_custom_fields
socket.assigns.all_custom_fields,
compact_member: cm,
member_include_email: ie,
compact_address: ca
)
end
@ -941,6 +972,181 @@ defmodule MvWeb.MemberLive.Index do
end
end
# The connect-info Plug.Conn (present on the initial dead render), used to read
# per-browser cookies such as the persisted view settings.
defp connect_conn(socket) do
case socket.private[:connect_info] do
%Plug.Conn{} = conn -> conn
_ -> nil
end
end
# The LiveView connect params (present only on the connected mount). The client
# echoes the persisted view-settings cookie here because the connect-info map
# of a live socket does not expose cookies.
defp connect_params(socket) do
if connected?(socket), do: get_connect_params(socket), else: nil
end
# Assigns the full view-settings map plus the derived per-setting assigns the
# template renders from.
defp assign_view_settings(socket, settings) do
socket
|> assign(:view_settings, settings)
|> assign(:density, settings.density)
|> assign(:compact_member, settings.compact_member)
|> assign(:member_include_email, settings.member_include_email)
|> assign(:compact_address, settings.compact_address)
end
# Updates a single view setting, re-derives the template assigns and persists
# the whole settings map per browser (client writes the cookie; the connected
# mount reads it back via connect params).
defp update_view_setting(socket, key, value) do
settings = Map.put(socket.assigns.view_settings, key, value)
socket
|> assign_view_settings(settings)
|> push_event("store-view-settings", %{view_settings: ViewSettings.to_json(settings)})
end
# Flips the density setting. This only re-renders the table wrapper attribute
# (row spacing token), so the streamed rows do not need to be re-rendered.
defp toggle_view_setting(socket, :density) do
new = if socket.assigns.density == :compact, do: :comfortable, else: :compact
update_view_setting(socket, :density, new)
end
# Flips the "include email" sub-toggle. Only meaningful while the composite
# Member cell is on: it moves the email between an in-cell line (on) and a
# separate E-Mail column (off), so besides re-rendering the rows it recomputes
# which columns the manager offers and their visibility.
defp toggle_view_setting(socket, :member_include_email) do
new_value = not socket.assigns.view_settings.member_include_email
socket
|> update_view_setting(:member_include_email, new_value)
|> apply_include_email_toggle(new_value)
|> load_members()
|> prepare_dynamic_cols()
|> update_selection_assigns()
|> push_field_selection_url()
end
# Flips the compact Member field. Besides re-rendering the rows, this changes
# which columns the manager offers (composite "Name" vs. Vorname/Nachname/E-Mail,
# §7b). The email visibility carries over between the two representations,
# analogously to the "include email" sub-toggle: composite + include-email ⇄ the
# E-Mail column being visible.
defp toggle_view_setting(socket, :compact_member) do
new_value = not socket.assigns.view_settings.compact_member
email_visible? = email_currently_visible?(socket)
socket
|> update_view_setting(:compact_member, new_value)
|> sync_include_email(new_value, email_visible?)
|> apply_member_toggle(new_value, email_visible?)
|> load_members()
|> prepare_dynamic_cols()
|> update_selection_assigns()
|> push_field_selection_url()
end
# Flips the compact Address field: offers the composite "Adresse" vs. the
# separate Straße/Hausnummer/PLZ/Ort columns, making the newly relevant columns
# visible and recomputing the offered set + visibility for the new mode.
defp toggle_view_setting(socket, :compact_address) do
new_value = not socket.assigns.view_settings.compact_address
fields = if new_value, do: [:address], else: [:street, :house_number, :postal_code, :city]
socket
|> update_view_setting(:compact_address, new_value)
|> apply_composite_toggle(fields)
|> load_members()
|> prepare_dynamic_cols()
|> update_selection_assigns()
|> push_field_selection_url()
end
# Whether the member email is currently surfaced: while the composite is on,
# either folded into the cell (include_email) or as a separate E-Mail column;
# while it is off, as its own column. Read before the flip, so `compact_member`
# still holds the previous mode.
defp email_currently_visible?(socket) do
if socket.assigns.compact_member do
socket.assigns.member_include_email or :email in socket.assigns.member_fields_visible
else
:email in socket.assigns.member_fields_visible
end
end
# Turning include_email OFF surfaces the email as a separate column; make it
# visible. Turning it ON folds the email into the cell, so the column is no
# longer offered (the recompute drops it).
defp apply_include_email_toggle(socket, false = _include_email) do
selection = Map.put(socket.assigns.user_field_selection, "email", true)
recompute_offered(socket, selection)
end
defp apply_include_email_toggle(socket, true = _include_email),
do: recompute_offered(socket, socket.assigns.user_field_selection)
# When switching to the composite, mirror the email state onto the include-email
# sub-toggle so the email keeps being surfaced; when switching away, the
# sub-toggle is not applicable.
defp sync_include_email(socket, true = _new_compact, email_visible?),
do: update_view_setting(socket, :member_include_email, email_visible?)
defp sync_include_email(socket, false = _new_compact, _email_visible?), do: socket
# Makes the now-relevant Member columns visible for the new mode. Turning the
# composite on surfaces "Name" (with the email either folded via include_email
# or kept as a separate column); turning it off surfaces Vorname/Nachname and
# carries the prior email visibility onto the E-Mail column.
defp apply_member_toggle(socket, true = _new_compact, email_visible?) do
selection =
socket.assigns.user_field_selection
|> Map.put("name", true)
|> Map.put("email", email_visible?)
recompute_offered(socket, selection)
end
defp apply_member_toggle(socket, false = _new_compact, email_visible?) do
selection =
socket.assigns.user_field_selection
|> Map.put("first_name", true)
|> Map.put("last_name", true)
|> Map.put("email", email_visible?)
recompute_offered(socket, selection)
end
# Sets the given columns visible, then recomputes the offered set + visibility.
defp apply_composite_toggle(socket, fields) do
selection =
Enum.reduce(fields, socket.assigns.user_field_selection, fn field, acc ->
Map.put(acc, Atom.to_string(field), true)
end)
recompute_offered(socket, selection)
end
# Recomputes the offered column list and the derived visibility for the current
# mode. `update_view_setting` has already updated the compact_* assigns, so
# `view_flags/1` reflects the new mode here.
defp recompute_offered(socket, selection) do
{cm, ie, ca} = view_flags(socket)
socket
|> assign(
:all_available_fields,
FieldVisibility.get_offered_fields(socket.assigns.all_custom_fields, cm, ie, ca)
)
|> assign_field_visibility(selection)
end
# Parses optional "highlight" URL param (member id for selected row styling). Returns nil if missing or invalid.
defp parse_highlight_param(nil), do: nil
defp parse_highlight_param(""), do: nil

View file

@ -5,7 +5,6 @@
<.live_component
module={MvWeb.Components.BulkActionsDropdown}
id="bulk-actions-dropdown"
open={@bulk_actions_open}
export_payload_json={@export_payload_json}
selected_count={@selected_count}
scope={@scope}
@ -72,6 +71,7 @@
</span>
</.button>
</.tooltip>
<div class="ml-auto flex items-center gap-4">
<.live_component
module={MvWeb.Components.FieldVisibilityDropdownComponent}
id="field-visibility-dropdown"
@ -79,6 +79,15 @@
custom_fields={@all_custom_fields}
selected_fields={@user_field_selection}
/>
<.live_component
module={MvWeb.Components.ViewSettingsDropdownComponent}
id="view-settings-dropdown"
density={@density}
compact_member={@compact_member}
member_include_email={@member_include_email}
compact_address={@compact_address}
/>
</div>
</div>
<%!-- Polite live region: present on first render (before it is filled) so
@ -103,6 +112,8 @@
role="region"
aria-label={gettext("Members table")}
aria-busy={to_string(@loading?)}
data-density={@density}
tabindex="0"
>
<.table
id="members"
@ -122,6 +133,7 @@
dynamic_cols={@dynamic_cols}
sort_field={@sort_field}
sort_order={@sort_order}
size_class={if @density == :compact, do: "table-xs", else: "table-md"}
>
<!-- <:col :let={member} label="Id">{member.id}</:col> -->
@ -130,10 +142,14 @@
col_click={&MvWeb.MemberLive.Index.checkbox_column_click/1}
label={
~H"""
<.input
<input
type="checkbox"
id="select-all-checkbox"
name="select_all"
class="checkbox checkbox-sm"
phx-click="select_all"
phx-hook="IndeterminateCheckbox"
data-indeterminate={to_string(@selected_count > 0 and @selected_count < @total_count)}
checked={@total_count > 0 and @selected_count == @total_count}
aria-label={gettext("Select all members")}
role="checkbox"
@ -141,9 +157,10 @@
"""
}
>
<.input
<input
type="checkbox"
name={member.id}
class="checkbox checkbox-sm"
checked={MapSet.member?(@selected_members, member.id)}
aria-label={gettext("Select member")}
role="checkbox"
@ -151,8 +168,21 @@
</:col>
<:col
:let={member}
:if={:name in @member_fields_visible}
:if={:name in @member_fields_visible and @compact_member}
sort_field={:name}
label={
~H"""
<.live_component
module={MvWeb.Components.SortHeaderComponent}
id={:sort_name}
field={:name}
label={gettext("Name")}
sort_field={@sort_field}
sort_order={@sort_order}
sort_hint={gettext("Click to sort by last name")}
/>
"""
}
>
<% full_name =
[member.first_name, member.last_name]
@ -163,7 +193,8 @@
{full_name}
</div>
<div
:if={member.email not in [nil, ""]}
:if={@member_include_email and member.email not in [nil, ""]}
data-testid="member-name-email"
class="opacity-70 truncate"
title={member.email}
>
@ -171,29 +202,10 @@
</div>
</div>
</:col>
<:col
:let={member}
:if={:address in @member_fields_visible}
label={gettext("Address")}
>
<% line1 =
[member.street, member.house_number]
|> Enum.reject(&(&1 in [nil, ""]))
|> Enum.join(" ") %>
<% line2 =
[member.postal_code, member.city]
|> Enum.reject(&(&1 in [nil, ""]))
|> Enum.join(" ") %>
<.maybe_value value={line1 <> line2} empty_sr_text={gettext("No address")}>
<div data-testid="member-address">
<div class="truncate" title={line1}>{line1}</div>
<div class="opacity-70 truncate" title={line2}>{line2}</div>
</div>
</.maybe_value>
</:col>
<:col
:let={member}
:if={:first_name in @member_fields_visible}
sort_field={:first_name}
label={
~H"""
<.live_component
@ -212,6 +224,7 @@
<:col
:let={member}
:if={:last_name in @member_fields_visible}
sort_field={:last_name}
label={
~H"""
<.live_component
@ -230,6 +243,7 @@
<:col
:let={member}
:if={:email in @member_fields_visible}
sort_field={:email}
label={
~H"""
<.live_component
@ -245,9 +259,117 @@
>
{member.email}
</:col>
<:col
:let={member}
:if={:address in @member_fields_visible and @compact_address}
sort_field={:address}
label={
~H"""
<.live_component
module={MvWeb.Components.SortHeaderComponent}
id={:sort_address}
field={:address}
label={gettext("Address")}
sort_field={@sort_field}
sort_order={@sort_order}
sort_hint={gettext("Click to sort by city")}
/>
"""
}
>
<% line1 =
[member.street, member.house_number]
|> Enum.reject(&(&1 in [nil, ""]))
|> Enum.join(" ") %>
<% line2 =
[member.postal_code, member.city]
|> Enum.reject(&(&1 in [nil, ""]))
|> Enum.join(" ") %>
<div data-testid="member-address">
<div class="truncate" title={line1}>{line1}</div>
<div class="opacity-70 truncate" title={line2}>{line2}</div>
</div>
</:col>
<:col
:let={member}
:if={:street in @member_fields_visible}
sort_field={:street}
label={
~H"""
<.live_component
module={MvWeb.Components.SortHeaderComponent}
id={:sort_street}
field={:street}
label={gettext("Street")}
sort_field={@sort_field}
sort_order={@sort_order}
/>
"""
}
>
{member.street}
</:col>
<:col
:let={member}
:if={:house_number in @member_fields_visible}
sort_field={:house_number}
label={
~H"""
<.live_component
module={MvWeb.Components.SortHeaderComponent}
id={:sort_house_number}
field={:house_number}
label={gettext("House Number")}
sort_field={@sort_field}
sort_order={@sort_order}
/>
"""
}
>
{member.house_number}
</:col>
<:col
:let={member}
:if={:postal_code in @member_fields_visible}
sort_field={:postal_code}
label={
~H"""
<.live_component
module={MvWeb.Components.SortHeaderComponent}
id={:sort_postal_code}
field={:postal_code}
label={gettext("Postal Code")}
sort_field={@sort_field}
sort_order={@sort_order}
/>
"""
}
>
{member.postal_code}
</:col>
<:col
:let={member}
:if={:city in @member_fields_visible}
sort_field={:city}
label={
~H"""
<.live_component
module={MvWeb.Components.SortHeaderComponent}
id={:sort_city}
field={:city}
label={gettext("City")}
sort_field={@sort_field}
sort_order={@sort_order}
/>
"""
}
>
{member.city}
</:col>
<:col
:let={member}
:if={:join_date in @member_fields_visible}
sort_field={:join_date}
label={
~H"""
<.live_component
@ -266,6 +388,7 @@
<:col
:let={member}
:if={:exit_date in @member_fields_visible}
sort_field={:exit_date}
label={
~H"""
<.live_component
@ -291,6 +414,7 @@
<:col
:let={member}
:if={:country in @member_fields_visible}
sort_field={:country}
label={
~H"""
<.live_component
@ -306,89 +430,10 @@
>
{member.country}
</:col>
<:col
:let={member}
:if={:city in @member_fields_visible}
label={
~H"""
<.live_component
module={MvWeb.Components.SortHeaderComponent}
id={:sort_city}
field={:city}
label={gettext("City")}
sort_field={@sort_field}
sort_order={@sort_order}
/>
"""
}
>
<.maybe_value value={member.city} empty_sr_text={gettext("Not specified")}>
{member.city}
</.maybe_value>
</:col>
<:col
:let={member}
:if={:street in @member_fields_visible}
label={
~H"""
<.live_component
module={MvWeb.Components.SortHeaderComponent}
id={:sort_street}
field={:street}
label={gettext("Street")}
sort_field={@sort_field}
sort_order={@sort_order}
/>
"""
}
>
<.maybe_value value={member.street} empty_sr_text={gettext("Not specified")}>
{member.street}
</.maybe_value>
</:col>
<:col
:let={member}
:if={:house_number in @member_fields_visible}
label={
~H"""
<.live_component
module={MvWeb.Components.SortHeaderComponent}
id={:sort_house_number}
field={:house_number}
label={gettext("House Number")}
sort_field={@sort_field}
sort_order={@sort_order}
/>
"""
}
>
<.maybe_value value={member.house_number} empty_sr_text={gettext("Not specified")}>
{member.house_number}
</.maybe_value>
</:col>
<:col
:let={member}
:if={:postal_code in @member_fields_visible}
label={
~H"""
<.live_component
module={MvWeb.Components.SortHeaderComponent}
id={:sort_postal_code}
field={:postal_code}
label={gettext("Postal Code")}
sort_field={@sort_field}
sort_order={@sort_order}
/>
"""
}
>
<.maybe_value value={member.postal_code} empty_sr_text={gettext("Not specified")}>
{member.postal_code}
</.maybe_value>
</:col>
<:col
:let={member}
:if={:membership_fee_start_date in @member_fields_visible}
sort_field={:membership_fee_start_date}
label={
~H"""
<.live_component
@ -407,6 +452,7 @@
<:col
:let={member}
:if={:membership_fee_type in @member_fields_visible}
sort_field={:membership_fee_type}
label={
~H"""
<.live_component
@ -443,6 +489,7 @@
<:col
:let={member}
:if={:groups in @member_fields_visible}
sort_field={:groups}
label={
~H"""
<.live_component
@ -491,11 +538,17 @@
class="h-0"
>
</div>
<%!-- The accent (background + top separator) and row height live on the
full-width footer cell (see #members-footer in app.css) so they span the
whole table edge-to-edge and match the active density. This inner element
stays pinned to the visible viewport width via StickyViewportWidth and is
transparent, so the cell accent shows through while the spinner/label stay
centered during horizontal scroll. --%>
<div
id="members-loading-bar"
phx-hook="StickyViewportWidth"
data-scroll-container="members-table-guard"
class="sticky left-0 flex items-center justify-center gap-2 border-t border-base-300 bg-base-200 py-4 text-base-content/70"
class="sticky left-0 flex items-center justify-center gap-2 text-base-content/70"
data-testid="members-loading-row"
>
<span class="loading loading-spinner loading-sm" aria-hidden="true"></span>

View file

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

View 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

View file

@ -4,11 +4,25 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
# The curated default columns hide the individual name/address sort headers,
# so these component tests make every sortable field visible via ?fields=.
# The individual name/address constituents are only *offered* when the compact
# Member / Address view settings are off, so the session opts them out of the
# composite columns before selecting the fields via ?fields=.
@cols "first_name,email,street,house_number,postal_code,city,country,join_date"
# Disables the compact Member/Address composites so first_name/last_name/email
# and street/house_number/postal_code/city are offered as individual sortable
# columns (persisted per browser via the view-settings session key).
defp non_compact(conn) do
Plug.Conn.put_session(
conn,
"member_view_settings",
~s({"compact_member":false,"compact_address":false})
)
end
describe "rendering" do
test "renders with correct attributes", %{conn: conn} do
conn = conn_with_oidc_user(conn)
conn = conn |> conn_with_oidc_user() |> non_compact()
{:ok, view, _html} = live(conn, "/members?fields=#{@cols}")
# Test that the component renders with correct attributes
@ -18,7 +32,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
end
test "renders all sortable headers", %{conn: conn} do
conn = conn_with_oidc_user(conn)
conn = conn |> conn_with_oidc_user() |> non_compact()
{:ok, view, _html} = live(conn, "/members?fields=#{@cols}")
sortable_fields = [
@ -38,7 +52,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
end
test "renders correct labels", %{conn: conn} do
conn = conn_with_oidc_user(conn)
conn = conn |> conn_with_oidc_user() |> non_compact()
{:ok, view, _html} = live(conn, "/members?fields=#{@cols}")
# Test specific labels
@ -50,7 +64,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
describe "sort icons" do
test "shows neutral icon for specific field when not sorted", %{conn: conn} do
conn = conn_with_oidc_user(conn)
conn = conn |> conn_with_oidc_user() |> non_compact()
{:ok, view, _html} = live(conn, "/members?fields=#{@cols}")
# The neutral icon has the opcity class we can test for
@ -62,7 +76,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
end
test "shows ascending icon for specific field when sorted ascending", %{conn: conn} do
conn = conn_with_oidc_user(conn)
conn = conn |> conn_with_oidc_user() |> non_compact()
{:ok, view, html} =
live(conn, "/members?fields=#{@cols}&query=&sort_field=city&sort_order=asc")
@ -85,7 +99,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
end
test "shows descending icon for specific field when sorted descending", %{conn: conn} do
conn = conn_with_oidc_user(conn)
conn = conn |> conn_with_oidc_user() |> non_compact()
{:ok, _view, html} =
live(conn, "/members?fields=#{@cols}&query=&sort_field=email&sort_order=desc")
@ -99,7 +113,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
end
test "multiple fields can have different icon states", %{conn: conn} do
conn = conn_with_oidc_user(conn)
conn = conn |> conn_with_oidc_user() |> non_compact()
{:ok, view, _html} =
live(conn, "/members?fields=#{@cols}&query=&sort_field=city&sort_order=asc")
@ -118,7 +132,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
end
test "icon state changes correctly when clicking different fields", %{conn: conn} do
conn = conn_with_oidc_user(conn)
conn = conn |> conn_with_oidc_user() |> non_compact()
{:ok, view, _html} = live(conn, "/members?fields=#{@cols}")
# Start: all fields neutral except first name as default
@ -146,7 +160,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
end
test "specific field shows correct icon for each sort state", %{conn: conn} do
conn = conn_with_oidc_user(conn)
conn = conn |> conn_with_oidc_user() |> non_compact()
# Test EMAIL field specifically
{:ok, view, html_asc} =
@ -167,7 +181,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
end
test "icon distribution shows exactly one active sort icon", %{conn: conn} do
conn = conn_with_oidc_user(conn)
conn = conn |> conn_with_oidc_user() |> non_compact()
# Test neutral state - only one field should have active sort icon
{:ok, _view, html_neutral} = live(conn, "/members?fields=#{@cols}")
@ -204,7 +218,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
describe "accessibility" do
test "sets aria-label correctly for unsorted state", %{conn: conn} do
conn = conn_with_oidc_user(conn)
conn = conn |> conn_with_oidc_user() |> non_compact()
{:ok, view, _html} = live(conn, "/members?fields=#{@cols}")
# Check aria-label for unsorted state
@ -212,7 +226,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
end
test "sets aria-label correctly for ascending sort", %{conn: conn} do
conn = conn_with_oidc_user(conn)
conn = conn |> conn_with_oidc_user() |> non_compact()
{:ok, view, _html} =
live(conn, "/members?fields=#{@cols}&sort_field=first_name&sort_order=asc")
@ -222,7 +236,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
end
test "sets aria-label correctly for descending sort", %{conn: conn} do
conn = conn_with_oidc_user(conn)
conn = conn |> conn_with_oidc_user() |> non_compact()
{:ok, view, _html} =
live(conn, "/members?fields=#{@cols}&sort_field=first_name&sort_order=desc")
@ -232,7 +246,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
end
test "includes tooltip with correct aria-label", %{conn: conn} do
conn = conn_with_oidc_user(conn)
conn = conn |> conn_with_oidc_user() |> non_compact()
{:ok, view, _html} =
live(conn, "/members?fields=#{@cols}&sort_field=first_name&sort_order=asc")
@ -243,7 +257,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
end
test "aria-labels work for all sortable fields", %{conn: conn} do
conn = conn_with_oidc_user(conn)
conn = conn |> conn_with_oidc_user() |> non_compact()
{:ok, view, _html} = live(conn, "/members?fields=#{@cols}&sort_field=email&sort_order=desc")
# Test aria-labels for different fields
@ -260,7 +274,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
describe "component behavior" do
test "clicking triggers sort event on parent LiveView", %{conn: conn} do
conn = conn_with_oidc_user(conn)
conn = conn |> conn_with_oidc_user() |> non_compact()
{:ok, view, _html} = live(conn, "/members?fields=#{@cols}")
# Click on the first name sort header
@ -273,7 +287,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
end
test "component handles different field types correctly", %{conn: conn} do
conn = conn_with_oidc_user(conn)
conn = conn |> conn_with_oidc_user() |> non_compact()
{:ok, view, _html} = live(conn, "/members?fields=#{@cols}")
# Test that different field types render correctly
@ -285,7 +299,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
describe "edge cases" do
test "handles invalid sort field gracefully", %{conn: conn} do
conn = conn_with_oidc_user(conn)
conn = conn |> conn_with_oidc_user() |> non_compact()
{:ok, view, html} =
live(conn, "/members?fields=#{@cols}&sort_field=invalid_field&sort_order=asc")
@ -296,7 +310,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
end
test "handles invalid sort order gracefully", %{conn: conn} do
conn = conn_with_oidc_user(conn)
conn = conn |> conn_with_oidc_user() |> non_compact()
{:ok, view, html} =
live(conn, "/members?fields=#{@cols}&sort_field=first_name&sort_order=invalid")
@ -307,7 +321,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
end
test "handles empty sort parameters", %{conn: conn} do
conn = conn_with_oidc_user(conn)
conn = conn |> conn_with_oidc_user() |> non_compact()
{:ok, view, html} = live(conn, "/members?fields=#{@cols}&sort_field=&sort_order=")
# Should show neutral icons
@ -318,7 +332,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
describe "icon state transitions" do
test "icon changes when sorting state changes", %{conn: conn} do
conn = conn_with_oidc_user(conn)
conn = conn |> conn_with_oidc_user() |> non_compact()
{:ok, view, _html} = live(conn, "/members?fields=#{@cols}")
# Start with neutral state
@ -334,7 +348,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
end
test "multiple fields can be tested for icon states", %{conn: conn} do
conn = conn_with_oidc_user(conn)
conn = conn |> conn_with_oidc_user() |> non_compact()
{:ok, view, html} = live(conn, "/members?fields=#{@cols}&sort_field=email&sort_order=desc")
# Email should be active (descending)

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

View file

@ -0,0 +1,78 @@
defmodule MvWeb.MemberLive.IndexA11yHardeningTest do
@moduledoc """
§3.5 WCAG 2.2 AA posture: 24px interactive targets in both densities,
scroll-margin on rows so a focused row is not obscured by the sticky header,
select-all `indeterminate` for partial selection, and reflow markup keeping the
table in its own focusable scroll region.
"""
use MvWeb.ConnCase, async: false
import Phoenix.LiveViewTest
alias Mv.Helpers.SystemActor
@moduletag :ui
setup %{conn: conn} do
actor = SystemActor.get_system_actor()
members =
for i <- 1..3 do
{:ok, m} =
Mv.Membership.create_member(
%{first_name: "Wcag#{i}", last_name: "Row", email: "wcag#{i}@example.com"},
actor: actor
)
m
end
%{conn: conn_with_oidc_user(conn), members: members}
end
test "rows reserve scroll-margin under the sticky header", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/members")
assert html =~ "scroll-mt-16"
end
test "the table lives in its own focusable scroll region that reflows", %{conn: conn} do
{:ok, _view, html} = live(conn, ~p"/members")
# Focusable region (1.4.10: table scrolls within its own focusable region).
assert html =~ ~r/data-testid="members-table-scroll"[^>]*tabindex="0"/
# The toolbar reflows to one column on narrow viewports.
assert html =~ "flex-wrap"
end
test "interactive row targets meet the >=24px minimum in both densities", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/members")
# Compact (default) and comfortable both keep the >=24px checkbox target.
assert has_element?(view, "input[type='checkbox'].min-h-6.min-w-6")
# Switch to comfortable via the view-settings dropdown.
view |> element("[data-testid='view-settings-button']") |> render_click()
view |> element("[data-testid='view-setting-density']") |> render_click()
assert has_element?(view, "input[type='checkbox'].min-h-6.min-w-6")
end
test "select-all reflects indeterminate for a partial selection", %{
conn: conn,
members: members
} do
{:ok, view, _html} = live(conn, ~p"/members")
# No selection: not indeterminate.
assert has_element?(view, "#select-all-checkbox[data-indeterminate='false']")
# Select one of several -> partial -> indeterminate.
[m | _] = members
render_click(view, "select_member", %{"id" => m.id})
assert has_element?(view, "#select-all-checkbox[data-indeterminate='true']")
# Select all -> fully checked, not indeterminate.
view |> element("[phx-click='select_all']") |> render_click()
assert has_element?(view, "#select-all-checkbox[data-indeterminate='false']")
assert has_element?(view, "#select-all-checkbox[checked]")
end
end

View file

@ -0,0 +1,76 @@
defmodule MvWeb.MemberLive.IndexColumnManagerTest do
@moduledoc """
§1.6 The column manager sits in the filter toolbar, offers a per-column
visibility toggle plus All and None controls styled as buttons (not links),
and applies changes while preserving the existing persistence chain.
"""
use MvWeb.ConnCase, async: false
import Phoenix.LiveViewTest
alias Mv.Helpers.SystemActor
setup %{conn: conn} do
{:ok, _} =
Mv.Membership.create_member(
%{first_name: "Manager", last_name: "Columns", email: "manager@example.com"},
actor: SystemActor.get_system_actor()
)
%{conn: conn_with_oidc_user(conn)}
end
test "column manager trigger sits alongside the filter controls", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/members")
# Both the filter panel trigger and the column-manager trigger render in the
# same toolbar.
assert has_element?(view, ~s(button[aria-label="Filter members"]))
assert has_element?(view, "button[aria-controls='field-visibility-menu']")
end
test "All and None are buttons, not links, and per-column toggles are checkboxes", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/members")
view |> element("button[aria-controls='field-visibility-menu']") |> render_click()
# All / None render as buttons with button styling.
assert has_element?(view, "button.btn[phx-click='select_all']", "All")
assert has_element?(view, "button.btn[phx-click='select_none']", "None")
refute has_element?(view, "a[phx-click='select_all']")
refute has_element?(view, "a[phx-click='select_none']")
# Per-column visibility toggles are accessible checkbox menu items.
assert has_element?(view, "button[role='menuitemcheckbox'][phx-value-item='name']")
end
test "toggling a column applies and is reflected in the URL (persistence)", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/members")
view |> element("button[aria-controls='field-visibility-menu']") |> render_click()
view |> element("button[phx-value-item='join_date']") |> render_click()
# The selection is pushed to the URL fields param so it survives reloads.
path = assert_patch(view)
assert path =~ "fields="
refute has_element?(view, "[data-testid='join_date']")
end
test "a reset button restores the curated default column set", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/members")
view |> element("button[aria-controls='field-visibility-menu']") |> render_click()
# Reset is a button styled like All/None, sitting next to them, with a tooltip.
assert has_element?(view, "button.btn[phx-click='reset_fields']")
# Hide a curated-default column, then reset restores it.
view |> element("button[phx-value-item='join_date']") |> render_click()
assert_patch(view)
refute has_element?(view, "[data-testid='join_date']")
view |> element("button[phx-click='reset_fields']") |> render_click()
assert_patch(view)
assert has_element?(view, "[data-testid='join_date']")
end
end

View file

@ -1,8 +1,9 @@
defmodule MvWeb.MemberLive.IndexDefaultColumnsTest do
@moduledoc """
§1.2 With no persisted column selection, exactly the curated default columns
are visible: selection checkbox, Name (name + email), Address (composite),
fee type, fee status, groups, join date.
are visible: selection checkbox, Name (composite, no in-cell email line),
E-Mail (its own column while the compact Member field keeps the email line off),
Address (composite), fee type, fee status, groups, join date.
"""
use MvWeb.ConnCase, async: false
@ -34,6 +35,8 @@ defmodule MvWeb.MemberLive.IndexDefaultColumnsTest do
# Composite Name + Address cells.
assert has_element?(view, "[data-testid='member-name']")
assert has_element?(view, "[data-testid='member-address']")
# E-Mail is its own column by default (compact Member field, email line off).
assert has_element?(view, "[data-testid='email']")
# Fee type, fee status, groups, join date headers.
assert has_element?(view, "[data-testid='membership_fee_type']")
assert has_element?(view, "[data-testid='join_date']")
@ -44,7 +47,9 @@ defmodule MvWeb.MemberLive.IndexDefaultColumnsTest do
test "the individual name/address sub-fields are hidden by default", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/members")
for field <- ~w(first_name last_name email city street house_number postal_code country) do
# The composite Name/Address cells replace their constituents by default.
# E-Mail is excluded here: it is a default-visible column of its own.
for field <- ~w(first_name last_name city street house_number postal_code country) do
refute has_element?(view, "[data-testid='#{field}']")
end
end

View file

@ -0,0 +1,73 @@
defmodule MvWeb.MemberLive.IndexDensityTest do
@moduledoc """
§1.4 The density toggle switches the table row-spacing token and reflects the
active density in the control state.
§1.5 Density persists across reload per browser (session/cookie), with the
global default applying when nothing is stored.
"""
use MvWeb.ConnCase, async: false
import Phoenix.LiveViewTest
alias Mv.Helpers.SystemActor
setup %{conn: conn} do
{:ok, _} =
Mv.Membership.create_member(
%{first_name: "Dense", last_name: "Row", email: "dense@example.com"},
actor: SystemActor.get_system_actor()
)
%{conn: conn_with_oidc_user(conn)}
end
defp open_view_settings(view) do
view |> element("[data-testid='view-settings-button']") |> render_click()
view
end
test "defaults to compact when nothing is stored", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/members")
assert has_element?(view, "[data-testid='members-table-scroll'][data-density='compact']")
# The compact-mode toggle reflects the active (compact) state.
open_view_settings(view)
assert has_element?(view, "[data-testid='view-setting-density'][aria-checked='true']")
end
test "toggle switches the density token and the control state", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/members")
open_view_settings(view)
view |> element("[data-testid='view-setting-density']") |> render_click()
assert has_element?(view, "[data-testid='members-table-scroll'][data-density='comfortable']")
assert has_element?(view, "[data-testid='view-setting-density'][aria-checked='false']")
# Toggling again returns to compact.
view |> element("[data-testid='view-setting-density']") |> render_click()
assert has_element?(view, "[data-testid='members-table-scroll'][data-density='compact']")
end
test "restores a persisted density from the session on reload", %{conn: conn} do
conn =
Plug.Test.init_test_session(conn, %{
"member_view_settings" => ~s({"density":"comfortable"})
})
{:ok, view, _html} = live(conn, ~p"/members")
assert has_element?(view, "[data-testid='members-table-scroll'][data-density='comfortable']")
end
test "restores a persisted density on the connected mount via connect params", %{conn: conn} do
# Faithful reload round-trip: the connected mount cannot read the cookie
# (the live socket's connect-info map has no cookies), so the client echoes
# the persisted value through connect params. This is the path that was
# broken in the R1 accept (cookie written but not restored on mount).
conn = put_connect_params(conn, %{"view_settings" => ~s({"density":"comfortable"})})
{:ok, view, _html} = live(conn, ~p"/members")
assert has_element?(view, "[data-testid='members-table-scroll'][data-density='comfortable']")
end
end

View file

@ -22,6 +22,17 @@ defmodule MvWeb.MemberLive.IndexFieldVisibilityTest do
require Ash.Query
# Disables the compact Member/Address composites (per-browser view setting) so
# the individual constituent columns (first_name/last_name/email, street/…) are
# offered by the column manager and available via ?fields=.
defp non_compact(conn) do
Plug.Conn.put_session(
conn,
"member_view_settings",
~s({"compact_member":false,"compact_address":false})
)
end
setup do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
@ -112,7 +123,9 @@ defmodule MvWeb.MemberLive.IndexFieldVisibilityTest do
end
test "displays all member fields in dropdown", %{conn: conn} do
conn = conn_with_oidc_user(conn)
# The individual name/address constituents are only offered when the
# compact Member/Address composites are off, so opt out of them first.
conn = conn |> conn_with_oidc_user() |> non_compact()
{:ok, view, _html} = live(conn, "/members")
# Open dropdown
@ -148,9 +161,9 @@ defmodule MvWeb.MemberLive.IndexFieldVisibilityTest do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# The curated Name column carries the email, so it is visible initially.
# The curated Name column carries the member names, so they are visible.
html = render(view)
assert html =~ "alice@example.com"
assert html =~ "Anderson"
# Open dropdown and hide the Name column
view
@ -161,10 +174,10 @@ defmodule MvWeb.MemberLive.IndexFieldVisibilityTest do
|> element("button[phx-click='select_item'][phx-value-item='name']")
|> render_click()
# The Name column (and the email it carries) is no longer visible
# The Name column (and the names it carries) is no longer visible
html = render(view)
refute html =~ "alice@example.com"
refute html =~ "bob@example.com"
refute html =~ "Anderson"
refute html =~ "Brown"
end
test "hiding custom field removes it from display", %{conn: conn, custom_field: custom_field} do
@ -298,8 +311,8 @@ defmodule MvWeb.MemberLive.IndexFieldVisibilityTest do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members")
# All fields should be visible by default
assert html =~ "alice@example.com"
# The curated Name and Address columns are visible by default.
assert html =~ "Anderson"
assert html =~ "Main St"
end
@ -309,7 +322,7 @@ defmodule MvWeb.MemberLive.IndexFieldVisibilityTest do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Hide the curated Name column (which carries the email) via dropdown
# Hide the curated Name column (which carries the names) via dropdown
view
|> element("button[aria-controls='field-visibility-menu']")
|> render_click()
@ -319,7 +332,7 @@ defmodule MvWeb.MemberLive.IndexFieldVisibilityTest do
|> render_click()
html = render(view)
refute html =~ "alice@example.com"
refute html =~ "Anderson"
end
end
@ -328,16 +341,16 @@ defmodule MvWeb.MemberLive.IndexFieldVisibilityTest do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members?fields=")
# Should fall back to global settings
assert html =~ "alice@example.com"
# Should fall back to global settings (curated Name column visible)
assert html =~ "Anderson"
end
test "handles invalid field names in URL", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members?fields=invalid_field,another_invalid")
# Should ignore invalid fields and use defaults
assert html =~ "alice@example.com"
# Should ignore invalid fields and use defaults (curated Name column visible)
assert html =~ "Anderson"
end
test "handles custom field that doesn't exist", %{conn: conn} do
@ -429,9 +442,9 @@ defmodule MvWeb.MemberLive.IndexFieldVisibilityTest do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# The curated Name column carries the email, so it is visible initially.
# The curated Name column carries the names, so they are visible initially.
html = render(view)
assert html =~ "alice@example.com"
assert html =~ "Anderson"
# Open dropdown
view
@ -443,9 +456,9 @@ defmodule MvWeb.MemberLive.IndexFieldVisibilityTest do
|> element("button[phx-click='select_item'][phx-value-item='name']")
|> render_keydown(%{key: "Enter"})
# The Name column (and the email it carries) is no longer visible
# The Name column (and the names it carries) is no longer visible
html = render(view)
refute html =~ "alice@example.com"
refute html =~ "Anderson"
end
end
end

View file

@ -20,6 +20,16 @@ defmodule MvWeb.MemberLive.IndexGroupsUrlParamsTest do
require Ash.Query
# Disables the compact Member composite (per-browser view setting) so the
# individual first_name column is offered and selectable via ?fields=.
defp non_compact(conn) do
Plug.Conn.put_session(
conn,
"member_view_settings",
~s({"compact_member":false,"compact_address":false})
)
end
setup do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
@ -122,7 +132,7 @@ defmodule MvWeb.MemberLive.IndexGroupsUrlParamsTest do
member1: member1,
group1: group1
} do
conn = conn_with_oidc_user(conn)
conn = conn |> conn_with_oidc_user() |> non_compact()
{:ok, view, html} =
live(

View file

@ -38,6 +38,9 @@ defmodule MvWeb.MemberLive.IndexMemberFieldsDisplayTest do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members")
# The composite Member cell carries first + last name; the in-cell email
# line stays off by default, but the email is surfaced in its own column
# (compact Member field, include-email off).
for m <- [m1, m2], field <- [m.first_name, m.last_name, m.email] do
assert html =~ field
end

View file

@ -13,6 +13,19 @@ defmodule MvWeb.MemberLive.IndexTest do
alias Mv.Membership.CustomFieldValue
alias MvWeb.MemberLive.Index, as: MemberIndex
# Disables the compact Member/Address composites (per-browser view setting) so
# the individual constituent columns (first_name/last_name, street/…) are
# offered by the column manager and selectable via ?fields=. The composite
# E-Mail column is offered even in compact mode, so email-only tests do not
# need this.
defp non_compact(conn) do
Plug.Conn.put_session(
conn,
"member_view_settings",
~s({"compact_member":false,"compact_address":false})
)
end
describe "desktop layout: scroll container and sticky table header" do
@describetag :ui
@ -155,9 +168,11 @@ defmodule MvWeb.MemberLive.IndexTest do
describe "sorting integration" do
@describetag :ui
# The curated default columns no longer expose the individual name/address
# sort headers, so these tests make the relevant column visible via ?fields=.
# That puts a `fields` param on every push_patch, hence the relaxed
# substring assertions instead of exact patch strings.
# sort headers. The composite E-Mail column is offered even in compact mode
# (so email-only tests just add it via ?fields=), but the name/address
# constituents are only offered once their composite view setting is off —
# those tests additionally opt out via `non_compact/1`. Either way a `fields`
# param rides on every push_patch, hence the relaxed substring assertions.
test "clicking a column header toggles sort order and updates the URL", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members?fields=email")
@ -183,7 +198,7 @@ defmodule MvWeb.MemberLive.IndexTest do
end
test "clicking different column header resets order to ascending", %{conn: conn} do
conn = conn_with_oidc_user(conn)
conn = conn |> conn_with_oidc_user() |> non_compact()
{:ok, view, _html} =
live(conn, "/members?fields=first_name,email&sort_field=email&sort_order=desc")
@ -199,7 +214,7 @@ defmodule MvWeb.MemberLive.IndexTest do
end
test "all sortable columns work correctly", %{conn: conn} do
conn = conn_with_oidc_user(conn)
conn = conn |> conn_with_oidc_user() |> non_compact()
{:ok, view, _html} =
live(
@ -275,7 +290,7 @@ defmodule MvWeb.MemberLive.IndexTest do
end
test "handle_params handles invalid sort field gracefully", %{conn: conn} do
conn = conn_with_oidc_user(conn)
conn = conn |> conn_with_oidc_user() |> non_compact()
{:ok, view, _html} =
live(conn, "/members?fields=first_name&query=&sort_field=invalid_field&sort_order=asc")

View file

@ -0,0 +1,102 @@
defmodule MvWeb.MemberLive.IndexViewSettingsTest do
@moduledoc """
§1.20 the view-settings dropdown toggles: the composite "Member" and address
fields, the "include email" sub-toggle and their effect on the rendered
columns. Turning a composite off surfaces the underlying separate columns.
§1.2 email is not in the Member cell by default.
"""
use MvWeb.ConnCase, async: false
import Phoenix.LiveViewTest
alias Mv.Helpers.SystemActor
setup %{conn: conn} do
{:ok, _} =
Mv.Membership.create_member(
%{
first_name: "Ada",
last_name: "Lovelace",
email: "ada@example.com",
street: "Baker Street",
house_number: "221",
postal_code: "10115",
city: "Berlin"
},
actor: SystemActor.get_system_actor()
)
%{conn: conn_with_oidc_user(conn)}
end
defp open_view_settings(view) do
view |> element("[data-testid='view-settings-button']") |> render_click()
view
end
test "member cell hides the email line by default", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/members")
assert has_element?(view, "[data-testid='member-name']")
refute has_element?(view, "[data-testid='member-name-email']")
end
test "the include-email sub-toggle surfaces the email line in the member cell", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/members")
open_view_settings(view)
view |> element("[data-testid='view-setting-member-include-email']") |> render_click()
assert has_element?(view, "[data-testid='member-name-email']", "ada@example.com")
end
test "turning off the compact member field shows separate name columns", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/members")
open_view_settings(view)
view |> element("[data-testid='view-setting-compact-member']") |> render_click()
refute has_element?(view, "[data-testid='member-name']")
assert has_element?(view, "[data-testid='first_name']")
assert has_element?(view, "[data-testid='last_name']")
# With the composite off, the email is surfaced as its own column.
assert has_element?(view, "[data-testid='email']")
end
test "with the compact member field off, the include-email sub-toggle is hidden",
%{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/members")
open_view_settings(view)
# While the composite Member field is on, the sub-toggle is available.
assert has_element?(view, "[data-testid='view-setting-member-include-email']")
view |> element("[data-testid='view-setting-compact-member']") |> render_click()
# Once the composite is off, the email is a separate column, so the
# in-cell "include email" sub-toggle no longer applies and is hidden.
refute has_element?(view, "[data-testid='view-setting-member-include-email']")
assert has_element?(view, "[data-testid='email']")
end
test "the compact address field renders a composite address cell by default", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/members")
assert has_element?(view, "[data-testid='member-address']")
refute has_element?(view, "[data-testid='street']")
refute has_element?(view, "[data-testid='postal_code']")
refute has_element?(view, "[data-testid='city']")
end
test "turning off the compact address field shows separate address columns", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/members")
open_view_settings(view)
view |> element("[data-testid='view-setting-compact-address']") |> render_click()
refute has_element?(view, "[data-testid='member-address']")
assert has_element?(view, "[data-testid='street']")
assert has_element?(view, "[data-testid='postal_code']")
assert has_element?(view, "[data-testid='city']")
end
end