Rework member overview #553

Open
simon wants to merge 27 commits from issue/mitgliederverwaltung-547 into main
21 changed files with 1643 additions and 263 deletions
Showing only changes of commit dfc616257d - Show all commits

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, { let liveSocket = new LiveSocket("/live", Socket, {
longPollFallbackMs: 2500, longPollFallbackMs: 2500,
params: { params: {
_csrf_token: csrfToken, _csrf_token: csrfToken,
timezone: getBrowserTimezone() timezone: getBrowserTimezone(),
view_settings: getCookie("member_view_settings")
}, },
hooks: Hooks 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 // Listen for custom events from LiveView
window.addEventListener("phx:set-input-value", (e) => { window.addEventListener("phx:set-input-value", (e) => {
const {id, value} = e.detail 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 // Show progress bar on live navigation and form submits
topbar.config({barColors: {0: "#29d"}, shadowColor: "rgba(0, 0, 0, .3)"}) topbar.config({barColors: {0: "#29d"}, shadowColor: "rgba(0, 0, 0, .3)"})
window.addEventListener("phx:page-loading-start", _info => topbar.show(300)) window.addEventListener("phx:page-loading-start", _info => topbar.show(300))

View file

@ -250,7 +250,7 @@ defmodule MvWeb.CoreComponents do
attr :size, :any, attr :size, :any,
default: "md", default: "md",
doc: "Badge size: sm | md" doc: "Badge size: xs | sm | md"
attr :sr_label, :string, attr :sr_label, :string,
default: nil, default: nil,
@ -269,7 +269,7 @@ defmodule MvWeb.CoreComponents do
variant_class = "badge-#{variant}" variant_class = "badge-#{variant}"
style_class = badge_style_class(style) 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 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 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("outline"), do: "badge-outline"
defp badge_style_class(_), do: nil 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 """ @doc """
Renders a visually empty table cell with screen-reader-only text (WCAG). 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 :selected, :map, default: %{}
attr :open, :boolean, default: false, doc: "Whether the dropdown is open" 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_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 :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_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)" 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")} {gettext("None")}
</button> </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>
</div> </div>
</li> </li>
@ -938,6 +963,11 @@ defmodule MvWeb.CoreComponents do
attr :sort_field, :any, default: nil, doc: "current sort field" attr :sort_field, :any, default: nil, doc: "current sort field"
attr :sort_order, :atom, default: nil, doc: "current sort order" 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, attr :sticky_header, :boolean,
default: false, default: false,
doc: doc:
@ -1016,7 +1046,7 @@ defmodule MvWeb.CoreComponents do
data-sticky-first-col-rows={@sticky_first_col && "true"} data-sticky-first-col-rows={@sticky_first_col && "true"}
phx-hook={@row_click && "TableRowKeydown"} phx-hook={@row_click && "TableRowKeydown"}
> >
<table class="table table-zebra"> <table class={["table table-zebra", @size_class]}>
<thead> <thead>
<tr> <tr>
<th <th
@ -1223,7 +1253,7 @@ defmodule MvWeb.CoreComponents do
defp table_th_sticky_class(_), do: nil defp table_th_sticky_class(_), do: nil
defp sticky_th_classes, 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 """ @doc """
Renders a reorderable table (sortable list) with drag handle and keyboard support. 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 <.dropdown_menu
id="field-visibility-menu" id="field-visibility-menu"
icon="hero-adjustments-horizontal" icon="hero-adjustments-horizontal"
button_label={gettext("Show/Hide Columns")} button_label={gettext("Columns")}
items={@all_items} items={@all_items}
checkboxes={true} checkboxes={true}
selected={@selected_fields} selected={@selected_fields}
open={@open} open={@open}
show_select_buttons={true} show_select_buttons={true}
show_reset_button={true}
phx_target={@myself} phx_target={@myself}
/> />
</div> </div>
@ -115,6 +116,13 @@ defmodule MvWeb.Components.FieldVisibilityDropdownComponent do
{:noreply, assign(socket, :selected_fields, all)} {:noreply, assign(socket, :selected_fields, all)}
end 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 # select none
def handle_event("select_none", _params, socket) do def handle_event("select_none", _params, socket) do
none = none =

View file

@ -32,11 +32,14 @@ defmodule MvWeb.Components.SortHeaderComponent do
> >
{@label} {@label}
<%= if @sort_field == @field do %> <%= 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 %> <% else %>
<.icon <.icon
name="hero-chevron-up-down" name="hero-chevron-up-down"
class="opacity-40" class="sort-icon opacity-40"
/> />
<% end %> <% end %>
</.button> </.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.Formatter
alias MvWeb.MemberLive.Index.MembershipFeeStatus alias MvWeb.MemberLive.Index.MembershipFeeStatus
alias MvWeb.MemberLive.Index.OverviewQuery alias MvWeb.MemberLive.Index.OverviewQuery
alias MvWeb.MemberLive.Index.ViewSettings
require Ash.Query require Ash.Query
require Logger require Logger
@ -123,16 +124,31 @@ defmodule MvWeb.MemberLive.Index do
# Load user field selection from session # Load user field selection from session
session_selection = FieldSelection.get_from_session(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_available_fields =
all_custom_fields FieldVisibility.get_offered_fields(
|> FieldVisibility.get_all_available_fields() all_custom_fields,
compact_member,
member_include_email,
compact_address
)
initial_selection = initial_selection =
FieldVisibility.merge_with_global_settings( FieldVisibility.merge_with_global_settings(
session_selection, session_selection,
settings, settings,
all_custom_fields all_custom_fields,
compact_member: compact_member,
member_include_email: member_include_email,
compact_address: compact_address
) )
socket = socket =
@ -183,6 +199,7 @@ defmodule MvWeb.MemberLive.Index do
|> assign(:mailto_bcc, "") |> assign(:mailto_bcc, "")
|> assign(:recipient_count, 0) |> assign(:recipient_count, 0)
|> assign(:mailto_disabled?, false) |> assign(:mailto_disabled?, false)
|> assign_view_settings(view_settings)
|> stream_configure(:members, dom_id: &"row-#{&1.id}") |> stream_configure(:members, dom_id: &"row-#{&1.id}")
|> stream(:members, []) |> stream(:members, [])
|> assign_export_payload() |> assign_export_payload()
@ -385,6 +402,11 @@ defmodule MvWeb.MemberLive.Index do
- `{:fields_selected, selection}` - Select all/deselect all event from FieldVisibilityDropdownComponent - `{: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 @impl true
def handle_info({:search_changed, q}, socket) do def handle_info({:search_changed, q}, socket) do
socket = socket =
@ -604,34 +626,10 @@ defmodule MvWeb.MemberLive.Index do
@impl true @impl true
def handle_info({:field_toggled, field_string, visible}, socket) do def handle_info({:field_toggled, field_string, visible}, socket) do
new_selection = Map.put(socket.assigns.user_field_selection, field_string, visible) 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 =
socket socket
|> assign(:user_field_selection, final_selection) |> assign_field_visibility(new_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))
|> load_members() |> load_members()
|> prepare_dynamic_cols() |> prepare_dynamic_cols()
|> update_selection_assigns() |> update_selection_assigns()
@ -641,35 +639,29 @@ defmodule MvWeb.MemberLive.Index do
end end
@impl true @impl true
def handle_info({:fields_selected, selection}, socket) do def handle_info({:fields_reset}, socket) do
socket = update_session_field_selection(socket, selection) # 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( FieldVisibility.merge_with_global_settings(
selection, %{},
socket.assigns.settings, 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 = handle_info({:fields_selected, default_selection}, socket)
final_selection end
|> 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)
@impl true
def handle_info({:fields_selected, selection}, socket) do
socket = socket =
socket socket
|> assign(:user_field_selection, final_selection) |> assign_field_visibility(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))
|> load_members() |> load_members()
|> prepare_dynamic_cols() |> prepare_dynamic_cols()
|> update_selection_assigns() |> update_selection_assigns()
@ -697,18 +689,6 @@ defmodule MvWeb.MemberLive.Index do
url_selection = FieldSelection.parse_from_url(params) url_selection = FieldSelection.parse_from_url(params)
final_selection = compute_final_field_selection(fields_in_url?, url_selection, socket) 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 =
socket socket
|> maybe_update_search(params) |> maybe_update_search(params)
@ -721,11 +701,7 @@ defmodule MvWeb.MemberLive.Index do
|> maybe_update_show_current_cycle(params) |> maybe_update_show_current_cycle(params)
|> assign(:fields_in_url?, fields_in_url?) |> assign(:fields_in_url?, fields_in_url?)
|> assign(:query, params["query"]) |> assign(:query, params["query"])
|> assign(:user_field_selection, final_selection) |> assign_visibility_derivations(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(:selected_member_id, parse_highlight_param(params["highlight"])) |> assign(:selected_member_id, parse_highlight_param(params["highlight"]))
next_sig = build_signature(socket) next_sig = build_signature(socket)
@ -863,8 +839,52 @@ defmodule MvWeb.MemberLive.Index do
push_reload(socket, new_path) push_reload(socket, new_path)
end end
defp update_session_field_selection(socket, selection) do # The current composite view flags; the offered column set is scoped to these.
assign(socket, :user_field_selection, selection) 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 end
defp build_query_params(opts) when is_map(opts) do defp build_query_params(opts) when is_map(opts) do
@ -904,8 +924,14 @@ defmodule MvWeb.MemberLive.Index do
end end
defp compute_final_field_selection(true, url_selection, socket) do defp compute_final_field_selection(true, url_selection, socket) do
{cm, ie, ca} = view_flags(socket)
only_url = 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_members = FieldVisibility.get_visible_member_fields(only_url)
visible_custom = FieldVisibility.get_visible_custom_fields(only_url) visible_custom = FieldVisibility.get_visible_custom_fields(only_url)
@ -919,6 +945,8 @@ defmodule MvWeb.MemberLive.Index do
end end
defp compute_final_field_selection(false, url_selection, socket) do defp compute_final_field_selection(false, url_selection, socket) do
{cm, ie, ca} = view_flags(socket)
merged = merged =
FieldSelection.merge_sources( FieldSelection.merge_sources(
url_selection, url_selection,
@ -929,7 +957,10 @@ defmodule MvWeb.MemberLive.Index do
FieldVisibility.merge_with_global_settings( FieldVisibility.merge_with_global_settings(
merged, merged,
socket.assigns.settings, socket.assigns.settings,
socket.assigns.all_custom_fields socket.assigns.all_custom_fields,
compact_member: cm,
member_include_email: ie,
compact_address: ca
) )
end end
@ -941,6 +972,181 @@ defmodule MvWeb.MemberLive.Index do
end end
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. # 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(nil), do: nil
defp parse_highlight_param(""), do: nil defp parse_highlight_param(""), do: nil

View file

@ -5,7 +5,6 @@
<.live_component <.live_component
module={MvWeb.Components.BulkActionsDropdown} module={MvWeb.Components.BulkActionsDropdown}
id="bulk-actions-dropdown" id="bulk-actions-dropdown"
open={@bulk_actions_open}
export_payload_json={@export_payload_json} export_payload_json={@export_payload_json}
selected_count={@selected_count} selected_count={@selected_count}
scope={@scope} scope={@scope}
@ -72,6 +71,7 @@
</span> </span>
</.button> </.button>
</.tooltip> </.tooltip>
<div class="ml-auto flex items-center gap-4">
<.live_component <.live_component
module={MvWeb.Components.FieldVisibilityDropdownComponent} module={MvWeb.Components.FieldVisibilityDropdownComponent}
id="field-visibility-dropdown" id="field-visibility-dropdown"
@ -79,6 +79,15 @@
custom_fields={@all_custom_fields} custom_fields={@all_custom_fields}
selected_fields={@user_field_selection} 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> </div>
<%!-- Polite live region: present on first render (before it is filled) so <%!-- Polite live region: present on first render (before it is filled) so
@ -103,6 +112,8 @@
role="region" role="region"
aria-label={gettext("Members table")} aria-label={gettext("Members table")}
aria-busy={to_string(@loading?)} aria-busy={to_string(@loading?)}
data-density={@density}
tabindex="0"
> >
<.table <.table
id="members" id="members"
@ -122,6 +133,7 @@
dynamic_cols={@dynamic_cols} dynamic_cols={@dynamic_cols}
sort_field={@sort_field} sort_field={@sort_field}
sort_order={@sort_order} sort_order={@sort_order}
size_class={if @density == :compact, do: "table-xs", else: "table-md"}
> >
<!-- <:col :let={member} label="Id">{member.id}</:col> --> <!-- <:col :let={member} label="Id">{member.id}</:col> -->
@ -130,10 +142,14 @@
col_click={&MvWeb.MemberLive.Index.checkbox_column_click/1} col_click={&MvWeb.MemberLive.Index.checkbox_column_click/1}
label={ label={
~H""" ~H"""
<.input <input
type="checkbox" type="checkbox"
id="select-all-checkbox"
name="select_all" name="select_all"
class="checkbox checkbox-sm"
phx-click="select_all" 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} checked={@total_count > 0 and @selected_count == @total_count}
aria-label={gettext("Select all members")} aria-label={gettext("Select all members")}
role="checkbox" role="checkbox"
@ -141,9 +157,10 @@
""" """
} }
> >
<.input <input
type="checkbox" type="checkbox"
name={member.id} name={member.id}
class="checkbox checkbox-sm"
checked={MapSet.member?(@selected_members, member.id)} checked={MapSet.member?(@selected_members, member.id)}
aria-label={gettext("Select member")} aria-label={gettext("Select member")}
role="checkbox" role="checkbox"
@ -151,8 +168,21 @@
</:col> </:col>
<:col <:col
:let={member} :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")} label={gettext("Name")}
sort_field={@sort_field}
sort_order={@sort_order}
sort_hint={gettext("Click to sort by last name")}
/>
"""
}
> >
<% full_name = <% full_name =
[member.first_name, member.last_name] [member.first_name, member.last_name]
@ -163,7 +193,8 @@
{full_name} {full_name}
</div> </div>
<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" class="opacity-70 truncate"
title={member.email} title={member.email}
> >
@ -171,29 +202,10 @@
</div> </div>
</div> </div>
</:col> </: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 <:col
:let={member} :let={member}
:if={:first_name in @member_fields_visible} :if={:first_name in @member_fields_visible}
sort_field={:first_name}
label={ label={
~H""" ~H"""
<.live_component <.live_component
@ -212,6 +224,7 @@
<:col <:col
:let={member} :let={member}
:if={:last_name in @member_fields_visible} :if={:last_name in @member_fields_visible}
sort_field={:last_name}
label={ label={
~H""" ~H"""
<.live_component <.live_component
@ -230,6 +243,7 @@
<:col <:col
:let={member} :let={member}
:if={:email in @member_fields_visible} :if={:email in @member_fields_visible}
sort_field={:email}
label={ label={
~H""" ~H"""
<.live_component <.live_component
@ -245,9 +259,117 @@
> >
{member.email} {member.email}
</:col> </: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 <:col
:let={member} :let={member}
:if={:join_date in @member_fields_visible} :if={:join_date in @member_fields_visible}
sort_field={:join_date}
label={ label={
~H""" ~H"""
<.live_component <.live_component
@ -266,6 +388,7 @@
<:col <:col
:let={member} :let={member}
:if={:exit_date in @member_fields_visible} :if={:exit_date in @member_fields_visible}
sort_field={:exit_date}
label={ label={
~H""" ~H"""
<.live_component <.live_component
@ -291,6 +414,7 @@
<:col <:col
:let={member} :let={member}
:if={:country in @member_fields_visible} :if={:country in @member_fields_visible}
sort_field={:country}
label={ label={
~H""" ~H"""
<.live_component <.live_component
@ -306,89 +430,10 @@
> >
{member.country} {member.country}
</:col> </: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 <:col
:let={member} :let={member}
:if={:membership_fee_start_date in @member_fields_visible} :if={:membership_fee_start_date in @member_fields_visible}
sort_field={:membership_fee_start_date}
label={ label={
~H""" ~H"""
<.live_component <.live_component
@ -407,6 +452,7 @@
<:col <:col
:let={member} :let={member}
:if={:membership_fee_type in @member_fields_visible} :if={:membership_fee_type in @member_fields_visible}
sort_field={:membership_fee_type}
label={ label={
~H""" ~H"""
<.live_component <.live_component
@ -443,6 +489,7 @@
<:col <:col
:let={member} :let={member}
:if={:groups in @member_fields_visible} :if={:groups in @member_fields_visible}
sort_field={:groups}
label={ label={
~H""" ~H"""
<.live_component <.live_component
@ -491,11 +538,17 @@
class="h-0" class="h-0"
> >
</div> </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 <div
id="members-loading-bar" id="members-loading-bar"
phx-hook="StickyViewportWidth" phx-hook="StickyViewportWidth"
data-scroll-container="members-table-guard" 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" data-testid="members-loading-row"
> >
<span class="loading loading-spinner loading-sm" aria-hidden="true"></span> <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). # 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, # :name and :address are composite display-only columns (Name = name + email,
# Address = street/house number over postal code/city). # 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 # 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 # 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()] @spec default_visible_fields() :: [atom()]
def default_visible_fields, do: MapSet.to_list(@default_visible_fields) 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. @doc """
@export_only_alias :payment_status 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 defp overview_member_fields do
Mv.Constants.member_fields() ++ @pseudo_member_fields Mv.Constants.member_fields() ++ @pseudo_member_fields
@ -93,8 +238,28 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do
String.t() => boolean() String.t() => boolean()
} }
def selection_from_url_only(url_selection, custom_fields) when is_map(url_selection) do 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 -> Enum.reduce(all_fields, %{}, fn field, acc ->
field_string = field_to_string(field) field_string = field_to_string(field)
visible = Map.get(url_selection, field_string, false) visible = Map.get(url_selection, field_string, false)
@ -102,7 +267,13 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do
end) end)
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 """ @doc """
Merges user field selection with global settings. Merges user field selection with global settings.
@ -135,7 +306,31 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do
) :: %{String.t() => boolean()} ) :: %{String.t() => boolean()}
def merge_with_global_settings(user_selection, global_settings, custom_fields) do def merge_with_global_settings(user_selection, global_settings, custom_fields) do
all_fields = get_all_available_fields(custom_fields) 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 -> Enum.reduce(all_fields, %{}, fn field, acc ->
field_string = field_to_string(field) field_string = field_to_string(field)
@ -280,15 +475,15 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do
def get_visible_custom_fields(_), do: [] def get_visible_custom_fields(_), do: []
# Gets global visibility map from settings # Gets global visibility map from settings
defp get_global_visibility_map(settings, custom_fields) do defp get_global_visibility_map(settings, custom_fields, default_set) do
member_visibility = get_member_field_visibility_from_settings(settings) member_visibility = get_member_field_visibility_from_settings(settings, default_set)
custom_field_visibility = get_custom_field_visibility(custom_fields) custom_field_visibility = get_custom_field_visibility(custom_fields)
Map.merge(member_visibility, custom_field_visibility) Map.merge(member_visibility, custom_field_visibility)
end end
# Gets member field visibility from settings (domain fields from settings, pseudo fields default true) # 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 = visibility_config =
VisibilityConfig.normalize(Map.get(settings, :member_field_visibility, %{})) VisibilityConfig.normalize(Map.get(settings, :member_field_visibility, %{}))
@ -297,13 +492,13 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do
domain_map = domain_map =
Enum.reduce(domain_fields, %{}, fn field, acc -> Enum.reduce(domain_fields, %{}, fn field, acc ->
field_string = Atom.to_string(field) 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) show_in_overview = Map.get(visibility_config, field, default_visibility)
Map.put(acc, field_string, show_in_overview) Map.put(acc, field_string, show_in_overview)
end) end)
Enum.reduce(@pseudo_member_fields, domain_map, fn field, acc -> 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)
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, # The curated default columns hide the individual name/address sort headers,
# so these component tests make every sortable field visible via ?fields=. # 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" @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 describe "rendering" do
test "renders with correct attributes", %{conn: conn} 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}") {:ok, view, _html} = live(conn, "/members?fields=#{@cols}")
# Test that the component renders with correct attributes # Test that the component renders with correct attributes
@ -18,7 +32,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
end end
test "renders all sortable headers", %{conn: conn} do 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}") {:ok, view, _html} = live(conn, "/members?fields=#{@cols}")
sortable_fields = [ sortable_fields = [
@ -38,7 +52,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
end end
test "renders correct labels", %{conn: conn} do 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}") {:ok, view, _html} = live(conn, "/members?fields=#{@cols}")
# Test specific labels # Test specific labels
@ -50,7 +64,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
describe "sort icons" do describe "sort icons" do
test "shows neutral icon for specific field when not sorted", %{conn: conn} 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}") {:ok, view, _html} = live(conn, "/members?fields=#{@cols}")
# The neutral icon has the opcity class we can test for # The neutral icon has the opcity class we can test for
@ -62,7 +76,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
end end
test "shows ascending icon for specific field when sorted ascending", %{conn: conn} do 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} = {:ok, view, html} =
live(conn, "/members?fields=#{@cols}&query=&sort_field=city&sort_order=asc") live(conn, "/members?fields=#{@cols}&query=&sort_field=city&sort_order=asc")
@ -85,7 +99,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
end end
test "shows descending icon for specific field when sorted descending", %{conn: conn} do 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} = {:ok, _view, html} =
live(conn, "/members?fields=#{@cols}&query=&sort_field=email&sort_order=desc") live(conn, "/members?fields=#{@cols}&query=&sort_field=email&sort_order=desc")
@ -99,7 +113,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
end end
test "multiple fields can have different icon states", %{conn: conn} do 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} = {:ok, view, _html} =
live(conn, "/members?fields=#{@cols}&query=&sort_field=city&sort_order=asc") live(conn, "/members?fields=#{@cols}&query=&sort_field=city&sort_order=asc")
@ -118,7 +132,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
end end
test "icon state changes correctly when clicking different fields", %{conn: conn} do 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}") {:ok, view, _html} = live(conn, "/members?fields=#{@cols}")
# Start: all fields neutral except first name as default # Start: all fields neutral except first name as default
@ -146,7 +160,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
end end
test "specific field shows correct icon for each sort state", %{conn: conn} do 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 # Test EMAIL field specifically
{:ok, view, html_asc} = {:ok, view, html_asc} =
@ -167,7 +181,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
end end
test "icon distribution shows exactly one active sort icon", %{conn: conn} do 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 # Test neutral state - only one field should have active sort icon
{:ok, _view, html_neutral} = live(conn, "/members?fields=#{@cols}") {:ok, _view, html_neutral} = live(conn, "/members?fields=#{@cols}")
@ -204,7 +218,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
describe "accessibility" do describe "accessibility" do
test "sets aria-label correctly for unsorted state", %{conn: conn} 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}") {:ok, view, _html} = live(conn, "/members?fields=#{@cols}")
# Check aria-label for unsorted state # Check aria-label for unsorted state
@ -212,7 +226,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
end end
test "sets aria-label correctly for ascending sort", %{conn: conn} do 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} = {:ok, view, _html} =
live(conn, "/members?fields=#{@cols}&sort_field=first_name&sort_order=asc") live(conn, "/members?fields=#{@cols}&sort_field=first_name&sort_order=asc")
@ -222,7 +236,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
end end
test "sets aria-label correctly for descending sort", %{conn: conn} do 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} = {:ok, view, _html} =
live(conn, "/members?fields=#{@cols}&sort_field=first_name&sort_order=desc") live(conn, "/members?fields=#{@cols}&sort_field=first_name&sort_order=desc")
@ -232,7 +246,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
end end
test "includes tooltip with correct aria-label", %{conn: conn} do 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} = {:ok, view, _html} =
live(conn, "/members?fields=#{@cols}&sort_field=first_name&sort_order=asc") live(conn, "/members?fields=#{@cols}&sort_field=first_name&sort_order=asc")
@ -243,7 +257,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
end end
test "aria-labels work for all sortable fields", %{conn: conn} do 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") {:ok, view, _html} = live(conn, "/members?fields=#{@cols}&sort_field=email&sort_order=desc")
# Test aria-labels for different fields # Test aria-labels for different fields
@ -260,7 +274,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
describe "component behavior" do describe "component behavior" do
test "clicking triggers sort event on parent LiveView", %{conn: conn} 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}") {:ok, view, _html} = live(conn, "/members?fields=#{@cols}")
# Click on the first name sort header # Click on the first name sort header
@ -273,7 +287,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
end end
test "component handles different field types correctly", %{conn: conn} do 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}") {:ok, view, _html} = live(conn, "/members?fields=#{@cols}")
# Test that different field types render correctly # Test that different field types render correctly
@ -285,7 +299,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
describe "edge cases" do describe "edge cases" do
test "handles invalid sort field gracefully", %{conn: conn} 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} = {:ok, view, html} =
live(conn, "/members?fields=#{@cols}&sort_field=invalid_field&sort_order=asc") live(conn, "/members?fields=#{@cols}&sort_field=invalid_field&sort_order=asc")
@ -296,7 +310,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
end end
test "handles invalid sort order gracefully", %{conn: conn} do 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} = {:ok, view, html} =
live(conn, "/members?fields=#{@cols}&sort_field=first_name&sort_order=invalid") live(conn, "/members?fields=#{@cols}&sort_field=first_name&sort_order=invalid")
@ -307,7 +321,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
end end
test "handles empty sort parameters", %{conn: conn} do 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=") {:ok, view, html} = live(conn, "/members?fields=#{@cols}&sort_field=&sort_order=")
# Should show neutral icons # Should show neutral icons
@ -318,7 +332,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
describe "icon state transitions" do describe "icon state transitions" do
test "icon changes when sorting state changes", %{conn: conn} 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}") {:ok, view, _html} = live(conn, "/members?fields=#{@cols}")
# Start with neutral state # Start with neutral state
@ -334,7 +348,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
end end
test "multiple fields can be tested for icon states", %{conn: conn} do 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") {:ok, view, html} = live(conn, "/members?fields=#{@cols}&sort_field=email&sort_order=desc")
# Email should be active (descending) # Email should be active (descending)

View file

@ -201,6 +201,56 @@ defmodule MvWeb.MemberLive.Index.FieldVisibilityTest do
end end
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 describe "get_visible_fields/1" do
test "returns only fields with true visibility" do test "returns only fields with true visibility" do
selection = %{ 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 defmodule MvWeb.MemberLive.IndexDefaultColumnsTest do
@moduledoc """ @moduledoc """
§1.2 With no persisted column selection, exactly the curated default columns §1.2 With no persisted column selection, exactly the curated default columns
are visible: selection checkbox, Name (name + email), Address (composite), are visible: selection checkbox, Name (composite, no in-cell email line),
fee type, fee status, groups, join date. 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 use MvWeb.ConnCase, async: false
@ -34,6 +35,8 @@ defmodule MvWeb.MemberLive.IndexDefaultColumnsTest do
# Composite Name + Address cells. # Composite Name + Address cells.
assert has_element?(view, "[data-testid='member-name']") assert has_element?(view, "[data-testid='member-name']")
assert has_element?(view, "[data-testid='member-address']") 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. # Fee type, fee status, groups, join date headers.
assert has_element?(view, "[data-testid='membership_fee_type']") assert has_element?(view, "[data-testid='membership_fee_type']")
assert has_element?(view, "[data-testid='join_date']") 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 test "the individual name/address sub-fields are hidden by default", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/members") {: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}']") refute has_element?(view, "[data-testid='#{field}']")
end end
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 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 setup do
system_actor = Mv.Helpers.SystemActor.get_system_actor() system_actor = Mv.Helpers.SystemActor.get_system_actor()
@ -112,7 +123,9 @@ defmodule MvWeb.MemberLive.IndexFieldVisibilityTest do
end end
test "displays all member fields in dropdown", %{conn: conn} do 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") {:ok, view, _html} = live(conn, "/members")
# Open dropdown # Open dropdown
@ -148,9 +161,9 @@ defmodule MvWeb.MemberLive.IndexFieldVisibilityTest do
conn = conn_with_oidc_user(conn) conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members") {: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) html = render(view)
assert html =~ "alice@example.com" assert html =~ "Anderson"
# Open dropdown and hide the Name column # Open dropdown and hide the Name column
view view
@ -161,10 +174,10 @@ defmodule MvWeb.MemberLive.IndexFieldVisibilityTest do
|> element("button[phx-click='select_item'][phx-value-item='name']") |> element("button[phx-click='select_item'][phx-value-item='name']")
|> render_click() |> 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) html = render(view)
refute html =~ "alice@example.com" refute html =~ "Anderson"
refute html =~ "bob@example.com" refute html =~ "Brown"
end end
test "hiding custom field removes it from display", %{conn: conn, custom_field: custom_field} do 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) conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members") {:ok, _view, html} = live(conn, "/members")
# All fields should be visible by default # The curated Name and Address columns are visible by default.
assert html =~ "alice@example.com" assert html =~ "Anderson"
assert html =~ "Main St" assert html =~ "Main St"
end end
@ -309,7 +322,7 @@ defmodule MvWeb.MemberLive.IndexFieldVisibilityTest do
conn = conn_with_oidc_user(conn) conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members") {: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 view
|> element("button[aria-controls='field-visibility-menu']") |> element("button[aria-controls='field-visibility-menu']")
|> render_click() |> render_click()
@ -319,7 +332,7 @@ defmodule MvWeb.MemberLive.IndexFieldVisibilityTest do
|> render_click() |> render_click()
html = render(view) html = render(view)
refute html =~ "alice@example.com" refute html =~ "Anderson"
end end
end end
@ -328,16 +341,16 @@ defmodule MvWeb.MemberLive.IndexFieldVisibilityTest do
conn = conn_with_oidc_user(conn) conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members?fields=") {:ok, _view, html} = live(conn, "/members?fields=")
# Should fall back to global settings # Should fall back to global settings (curated Name column visible)
assert html =~ "alice@example.com" assert html =~ "Anderson"
end end
test "handles invalid field names in URL", %{conn: conn} do test "handles invalid field names in URL", %{conn: conn} do
conn = conn_with_oidc_user(conn) conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members?fields=invalid_field,another_invalid") {:ok, _view, html} = live(conn, "/members?fields=invalid_field,another_invalid")
# Should ignore invalid fields and use defaults # Should ignore invalid fields and use defaults (curated Name column visible)
assert html =~ "alice@example.com" assert html =~ "Anderson"
end end
test "handles custom field that doesn't exist", %{conn: conn} do 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) conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members") {: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) html = render(view)
assert html =~ "alice@example.com" assert html =~ "Anderson"
# Open dropdown # Open dropdown
view view
@ -443,9 +456,9 @@ defmodule MvWeb.MemberLive.IndexFieldVisibilityTest do
|> element("button[phx-click='select_item'][phx-value-item='name']") |> element("button[phx-click='select_item'][phx-value-item='name']")
|> render_keydown(%{key: "Enter"}) |> 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) html = render(view)
refute html =~ "alice@example.com" refute html =~ "Anderson"
end end
end end
end end

View file

@ -20,6 +20,16 @@ defmodule MvWeb.MemberLive.IndexGroupsUrlParamsTest do
require Ash.Query 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 setup do
system_actor = Mv.Helpers.SystemActor.get_system_actor() system_actor = Mv.Helpers.SystemActor.get_system_actor()
@ -122,7 +132,7 @@ defmodule MvWeb.MemberLive.IndexGroupsUrlParamsTest do
member1: member1, member1: member1,
group1: group1 group1: group1
} do } do
conn = conn_with_oidc_user(conn) conn = conn |> conn_with_oidc_user() |> non_compact()
{:ok, view, html} = {:ok, view, html} =
live( live(

View file

@ -38,6 +38,9 @@ defmodule MvWeb.MemberLive.IndexMemberFieldsDisplayTest do
conn = conn_with_oidc_user(conn) conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members") {: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 for m <- [m1, m2], field <- [m.first_name, m.last_name, m.email] do
assert html =~ field assert html =~ field
end end

View file

@ -13,6 +13,19 @@ defmodule MvWeb.MemberLive.IndexTest do
alias Mv.Membership.CustomFieldValue alias Mv.Membership.CustomFieldValue
alias MvWeb.MemberLive.Index, as: MemberIndex 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 describe "desktop layout: scroll container and sticky table header" do
@describetag :ui @describetag :ui
@ -155,9 +168,11 @@ defmodule MvWeb.MemberLive.IndexTest do
describe "sorting integration" do describe "sorting integration" do
@describetag :ui @describetag :ui
# The curated default columns no longer expose the individual name/address # The curated default columns no longer expose the individual name/address
# sort headers, so these tests make the relevant column visible via ?fields=. # sort headers. The composite E-Mail column is offered even in compact mode
# That puts a `fields` param on every push_patch, hence the relaxed # (so email-only tests just add it via ?fields=), but the name/address
# substring assertions instead of exact patch strings. # 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 test "clicking a column header toggles sort order and updates the URL", %{conn: conn} do
conn = conn_with_oidc_user(conn) conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members?fields=email") {:ok, view, _html} = live(conn, "/members?fields=email")
@ -183,7 +198,7 @@ defmodule MvWeb.MemberLive.IndexTest do
end end
test "clicking different column header resets order to ascending", %{conn: conn} do 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} = {:ok, view, _html} =
live(conn, "/members?fields=first_name,email&sort_field=email&sort_order=desc") live(conn, "/members?fields=first_name,email&sort_field=email&sort_order=desc")
@ -199,7 +214,7 @@ defmodule MvWeb.MemberLive.IndexTest do
end end
test "all sortable columns work correctly", %{conn: conn} do 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} = {:ok, view, _html} =
live( live(
@ -275,7 +290,7 @@ defmodule MvWeb.MemberLive.IndexTest do
end end
test "handle_params handles invalid sort field gracefully", %{conn: conn} do 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} = {:ok, view, _html} =
live(conn, "/members?fields=first_name&query=&sort_field=invalid_field&sort_order=asc") 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