diff --git a/assets/js/app.js b/assets/js/app.js
index a6a7d694..85bbc6ba 100644
--- a/assets/js/app.js
+++ b/assets/js/app.js
@@ -414,15 +414,46 @@ Hooks.LoadMorePrefetch = {
}
}
+// Reads a browser cookie value by name (used to echo the persisted member
+// view-settings to the server on connect, since the live socket's connect-info
+// does not expose cookies).
+function getCookie(name) {
+ const match = document.cookie.match(new RegExp("(?:^|; )" + name + "=([^;]*)"))
+ return match ? decodeURIComponent(match[1]) : null
+}
+
let liveSocket = new LiveSocket("/live", Socket, {
longPollFallbackMs: 2500,
params: {
_csrf_token: csrfToken,
- timezone: getBrowserTimezone()
+ timezone: getBrowserTimezone(),
+ view_settings: getCookie("member_view_settings")
},
hooks: Hooks
})
+// Persist the member-overview view settings per browser/device (§1.5/§1.20). The
+// LiveView pushes "store-view-settings" (density + compact field toggles) on
+// change; we write a long-lived cookie that the LiveView reads back on the next
+// full page load (via the request cookie on the dead render and via connect
+// params on the connected mount).
+// Return the members list to the top after a sort or filter change. The server
+// resets the keyset stream to page 1 on those changes and pushes this event; a
+// user scrolled down would otherwise be stranded past the shorter content with
+// infinite scroll not re-arming. No-op if the scroll container is absent.
+window.addEventListener("phx:members:scroll-top", () => {
+ const el = document.getElementById("members-table-guard")
+ if (el) el.scrollTop = 0
+})
+
+window.addEventListener("phx:store-view-settings", (e) => {
+ const json = e.detail && e.detail.view_settings
+ if (typeof json === "string" && json.length > 0) {
+ const maxAge = 365 * 24 * 60 * 60
+ document.cookie = `member_view_settings=${encodeURIComponent(json)};path=/;max-age=${maxAge};samesite=lax`
+ }
+})
+
// Listen for custom events from LiveView
window.addEventListener("phx:set-input-value", (e) => {
const {id, value} = e.detail
@@ -432,15 +463,6 @@ window.addEventListener("phx:set-input-value", (e) => {
}
})
-// Return the members list to the top after a sort or filter change. The server
-// resets the keyset stream to page 1 on those changes and pushes this event; a
-// user scrolled down would otherwise be stranded past the shorter content with
-// infinite scroll not re-arming. No-op if the scroll container is absent.
-window.addEventListener("phx:members:scroll-top", () => {
- const el = document.getElementById("members-table-guard")
- if (el) el.scrollTop = 0
-})
-
// Show progress bar on live navigation and form submits
topbar.config({barColors: {0: "#29d"}, shadowColor: "rgba(0, 0, 0, .3)"})
window.addEventListener("phx:page-loading-start", _info => topbar.show(300))
diff --git a/lib/mv_web/components/core_components.ex b/lib/mv_web/components/core_components.ex
index 7af829a8..2b4dcde5 100644
--- a/lib/mv_web/components/core_components.ex
+++ b/lib/mv_web/components/core_components.ex
@@ -250,7 +250,7 @@ defmodule MvWeb.CoreComponents do
attr :size, :any,
default: "md",
- doc: "Badge size: sm | md"
+ doc: "Badge size: xs | sm | md"
attr :sr_label, :string,
default: nil,
@@ -269,7 +269,7 @@ defmodule MvWeb.CoreComponents do
variant_class = "badge-#{variant}"
style_class = badge_style_class(style)
- size_class = "badge-#{size}"
+ size_class = badge_size_class(size)
# Outline has transparent bg in DaisyUI; add bg so it stays visible on base-200/base-300
outline_bg = if style == "outline", do: "bg-base-100", else: nil
@@ -309,6 +309,13 @@ defmodule MvWeb.CoreComponents do
defp badge_style_class("outline"), do: "badge-outline"
defp badge_style_class(_), do: nil
+ # Literal strings so Tailwind's content scanner sees badge-xs / badge-sm / badge-md / badge-lg
+ # and does not purge them from the CSS bundle (string interpolation "badge-#{size}" would be invisible).
+ defp badge_size_class("xs"), do: "badge-xs"
+ defp badge_size_class("sm"), do: "badge-sm"
+ defp badge_size_class("lg"), do: "badge-lg"
+ defp badge_size_class(_), do: "badge-md"
+
@doc """
Renders a visually empty table cell with screen-reader-only text (WCAG).
@@ -443,6 +450,11 @@ defmodule MvWeb.CoreComponents do
attr :selected, :map, default: %{}
attr :open, :boolean, default: false, doc: "Whether the dropdown is open"
attr :show_select_buttons, :boolean, default: false, doc: "Show select all/none buttons"
+
+ attr :show_reset_button, :boolean,
+ default: false,
+ doc: "Show a reset-to-default icon button next to All/None (emits reset_fields)"
+
attr :phx_target, :any, required: true, doc: "The LiveView/LiveComponent target for events"
attr :menu_class, :string, default: nil, doc: "Additional CSS classes for the menu"
attr :menu_width, :string, default: "w-64", doc: "Width class for the menu (default: w-64)"
@@ -546,6 +558,19 @@ defmodule MvWeb.CoreComponents do
>
{gettext("None")}
+
@@ -938,6 +963,11 @@ defmodule MvWeb.CoreComponents do
attr :sort_field, :any, default: nil, doc: "current sort field"
attr :sort_order, :atom, default: nil, doc: "current sort order"
+ attr :size_class, :string,
+ default: "",
+ doc:
+ "optional DaisyUI table-size class controlling row density (e.g. table-xs, table-md); driven by the view-settings density value"
+
attr :sticky_header, :boolean,
default: false,
doc:
@@ -1016,7 +1046,7 @@ defmodule MvWeb.CoreComponents do
data-sticky-first-col-rows={@sticky_first_col && "true"}
phx-hook={@row_click && "TableRowKeydown"}
>
-
+
@@ -115,6 +116,13 @@ defmodule MvWeb.Components.FieldVisibilityDropdownComponent do
{:noreply, assign(socket, :selected_fields, all)}
end
+ # reset to the curated default column set (the parent owns the settings needed
+ # to compute the default, so it recomputes and re-applies the selection)
+ def handle_event("reset_fields", _params, socket) do
+ send(self(), {:fields_reset})
+ {:noreply, socket}
+ end
+
# select none
def handle_event("select_none", _params, socket) do
none =
diff --git a/lib/mv_web/live/components/sort_header_component.ex b/lib/mv_web/live/components/sort_header_component.ex
index c4850c41..d3ab1e94 100644
--- a/lib/mv_web/live/components/sort_header_component.ex
+++ b/lib/mv_web/live/components/sort_header_component.ex
@@ -32,11 +32,14 @@ defmodule MvWeb.Components.SortHeaderComponent do
>
{@label}
<%= if @sort_field == @field do %>
- <.icon name={if @sort_order == :asc, do: "hero-chevron-up", else: "hero-chevron-down"} />
+ <.icon
+ name={if @sort_order == :asc, do: "hero-chevron-up", else: "hero-chevron-down"}
+ class="sort-icon"
+ />
<% else %>
<.icon
name="hero-chevron-up-down"
- class="opacity-40"
+ class="sort-icon opacity-40"
/>
<% end %>
diff --git a/lib/mv_web/live/components/view_settings_dropdown_component.ex b/lib/mv_web/live/components/view_settings_dropdown_component.ex
new file mode 100644
index 00000000..b6cbec7a
--- /dev/null
+++ b/lib/mv_web/live/components/view_settings_dropdown_component.ex
@@ -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"""
+
+
+ <.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. --%>
+
diff --git a/lib/mv_web/live/member_live/index/field_visibility.ex b/lib/mv_web/live/member_live/index/field_visibility.ex
index 8db026d5..682b7b9f 100644
--- a/lib/mv_web/live/member_live/index/field_visibility.ex
+++ b/lib/mv_web/live/member_live/index/field_visibility.ex
@@ -31,7 +31,21 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do
# Groups and membership_fee_type are also pseudo fields (not in member_fields(), displayed in the table).
# :name and :address are composite display-only columns (Name = name + email,
# Address = street/house number over postal code/city).
- @pseudo_member_fields [:membership_fee_status, :membership_fee_type, :groups, :name, :address]
+ # Order mirrors the overview table / export column order (fee type before fee
+ # status), so the Columns dropdown and the table agree.
+ @pseudo_member_fields [:membership_fee_type, :membership_fee_status, :groups, :name, :address]
+
+ # The composite display columns and the constituent columns they replace. The
+ # column manager offers exactly one variant per group, selected by the view
+ # settings: the composite when the matching "compact" setting is on, otherwise
+ # the constituents (see `offered_member_fields/2`).
+ @name_composite :name
+ @name_constituents [:first_name, :last_name, :email]
+ @address_composite :address
+ @address_constituents [:street, :house_number, :postal_code, :city]
+
+ # Export/API may accept this as alias; must not appear in the UI options list.
+ @export_only_alias :payment_status
# Curated default-visible column set (§1.2): the columns shown on first visit
# when there is no persisted selection and no global override. Everything else
@@ -52,8 +66,139 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do
@spec default_visible_fields() :: [atom()]
def default_visible_fields, do: MapSet.to_list(@default_visible_fields)
- # Export/API may accept this as alias; must not appear in the UI options list.
- @export_only_alias :payment_status
+ @doc """
+ The curated default-visible columns for the given view settings.
+
+ In compact mode the composite `:name` / `:address` columns are visible by
+ default; when a composite is switched off, its constituent columns take over
+ as the defaults instead (§7b).
+
+ While the Member composite is compact, `include_email` decides where the email
+ lives: folded into the Member cell (`true`, no separate column), or as a
+ default-visible `:email` column (`false`).
+ """
+ @spec default_visible_fields(boolean(), boolean(), boolean()) :: [atom()]
+ def default_visible_fields(compact_member, include_email, compact_address) do
+ compact_member
+ |> default_visible_set(include_email, compact_address)
+ |> MapSet.to_list()
+ end
+
+ defp default_visible_set(compact_member, include_email, compact_address) do
+ @default_visible_fields
+ |> apply_name_default(compact_member, include_email)
+ |> apply_group_default(compact_address, @address_composite, @address_constituents)
+ end
+
+ # Compact Member cell with email folded in: only the composite is default.
+ defp apply_name_default(set, true = _compact, true = _include_email), do: set
+
+ # Compact Member cell without folded email: the composite plus a separate
+ # default-visible E-Mail column.
+ defp apply_name_default(set, true = _compact, false = _include_email),
+ do: MapSet.put(set, :email)
+
+ # Non-compact: drop the composite and make first/last name and email default.
+ defp apply_name_default(set, false = _compact, _include_email) do
+ set
+ |> MapSet.delete(@name_composite)
+ |> MapSet.union(MapSet.new(@name_constituents))
+ end
+
+ # Compact: the composite stays the default. Non-compact: drop the composite and
+ # make the constituents default-visible.
+ defp apply_group_default(set, true, _composite, _constituents), do: set
+
+ defp apply_group_default(set, false, composite, constituents) do
+ set
+ |> MapSet.delete(composite)
+ |> MapSet.union(MapSet.new(constituents))
+ end
+
+ @doc """
+ Member-field atoms the column manager offers for the given view settings, in
+ dropdown/table order.
+
+ The identity block always leads, regardless of whether each group is compact:
+
+ 1. the name group — the composite `:name` when compact, otherwise its
+ constituents `:first_name`, `:last_name`;
+ 2. the `:email` column, when offered as a separate column (it is folded into
+ the Member cell only while the composite is compact and `include_email` is
+ on);
+ 3. the address group — the composite `:address` when compact, otherwise its
+ constituents `:street`, `:house_number`, `:postal_code`, `:city`.
+
+ Everything else follows in the configured Datenfelder order. This keeps the
+ name group ahead of the address group even when the name group is expanded
+ into `:first_name`/`:last_name` while the address stays compact.
+ """
+ @spec offered_member_fields(boolean(), boolean(), boolean()) :: [atom()]
+ def offered_member_fields(compact_member, include_email, compact_address) do
+ name_group = if compact_member, do: [@name_composite], else: [:first_name, :last_name]
+ email_group = if email_offered?(compact_member, include_email), do: [:email], else: []
+
+ address_group =
+ if compact_address, do: [@address_composite], else: @address_constituents
+
+ leading = name_group ++ email_group ++ address_group
+
+ rest =
+ Enum.reject(overview_member_fields(), fn field ->
+ field == @export_only_alias or
+ hidden_variant?(field, compact_member, include_email, compact_address) or
+ field in leading
+ end)
+
+ leading ++ rest
+ end
+
+ # The email is a separate offered column except when it is folded into the
+ # compact Member cell (compact + include_email).
+ defp email_offered?(compact_member, include_email), do: not (compact_member and include_email)
+
+ # The variant of a composite group that is not offered in the current mode.
+ defp hidden_variant?(field, compact_member, include_email, compact_address) do
+ name_variant_hidden?(field, compact_member, include_email) or
+ address_variant_hidden?(field, compact_address)
+ end
+
+ # Compact hides Vorname/Nachname (folded into the composite). The email is
+ # hidden only when it is folded into the cell (compact + include_email); with
+ # include_email off it stays offered as a separate column. Non-compact hides the
+ # composite in favour of the constituents.
+ defp name_variant_hidden?(@name_composite, compact_member, _include_email),
+ do: not compact_member
+
+ defp name_variant_hidden?(:email, compact_member, include_email),
+ do: compact_member and include_email
+
+ defp name_variant_hidden?(field, compact_member, _include_email)
+ when field in [:first_name, :last_name],
+ do: compact_member
+
+ defp name_variant_hidden?(_field, _compact_member, _include_email), do: false
+
+ defp address_variant_hidden?(@address_composite, compact_address),
+ do: not compact_address
+
+ defp address_variant_hidden?(field, compact_address)
+ when field in [:street, :house_number, :postal_code, :city],
+ do: compact_address
+
+ defp address_variant_hidden?(_field, _compact_address), do: false
+
+ @doc """
+ All fields the column manager offers for the given view settings: the offered
+ member fields (see `offered_member_fields/3`) followed by the custom fields.
+ """
+ @spec get_offered_fields([struct()], boolean(), boolean(), boolean()) :: [
+ atom() | String.t()
+ ]
+ def get_offered_fields(custom_fields, compact_member, include_email, compact_address) do
+ custom_field_names = Enum.map(custom_fields, &"custom_field_#{&1.id}")
+ offered_member_fields(compact_member, include_email, compact_address) ++ custom_field_names
+ end
defp overview_member_fields do
Mv.Constants.member_fields() ++ @pseudo_member_fields
@@ -93,8 +238,28 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do
String.t() => boolean()
}
def selection_from_url_only(url_selection, custom_fields) when is_map(url_selection) do
- all_fields = get_all_available_fields(custom_fields)
+ do_selection_from_url_only(url_selection, get_all_available_fields(custom_fields))
+ end
+ def selection_from_url_only(_, _), do: %{}
+
+ @doc """
+ Like `selection_from_url_only/2`, but scoped to the columns offered for the
+ given view settings (`:compact_member` / `:member_include_email` /
+ `:compact_address` in `opts`).
+ """
+ @spec selection_from_url_only(%{String.t() => boolean()}, [struct()], keyword()) :: %{
+ String.t() => boolean()
+ }
+ def selection_from_url_only(url_selection, custom_fields, opts)
+ when is_map(url_selection) and is_list(opts) do
+ {cm, ie, ca} = view_opts(opts)
+ do_selection_from_url_only(url_selection, get_offered_fields(custom_fields, cm, ie, ca))
+ end
+
+ def selection_from_url_only(_, _, _), do: %{}
+
+ defp do_selection_from_url_only(url_selection, all_fields) do
Enum.reduce(all_fields, %{}, fn field, acc ->
field_string = field_to_string(field)
visible = Map.get(url_selection, field_string, false)
@@ -102,7 +267,13 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do
end)
end
- def selection_from_url_only(_, _), do: %{}
+ defp view_opts(opts) do
+ {
+ Keyword.get(opts, :compact_member, true),
+ Keyword.get(opts, :member_include_email, false),
+ Keyword.get(opts, :compact_address, true)
+ }
+ end
@doc """
Merges user field selection with global settings.
@@ -135,7 +306,31 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do
) :: %{String.t() => boolean()}
def merge_with_global_settings(user_selection, global_settings, custom_fields) do
all_fields = get_all_available_fields(custom_fields)
- global_visibility = get_global_visibility_map(global_settings, custom_fields)
+ do_merge(user_selection, global_settings, custom_fields, all_fields, @default_visible_fields)
+ end
+
+ @doc """
+ Like `merge_with_global_settings/3`, but scoped to the columns offered for the
+ given view settings (`:compact_member` / `:member_include_email` /
+ `:compact_address` in `opts`).
+
+ Only offered columns appear in the result, so a column that is not applicable
+ in the current mode (e.g. `:first_name` while the composite "Name" is on) never
+ leaks into the visible set. Defaults follow the mode via
+ `default_visible_fields/2`.
+ """
+ @spec merge_with_global_settings(%{String.t() => boolean()}, map(), [struct()], keyword()) ::
+ %{String.t() => boolean()}
+ def merge_with_global_settings(user_selection, global_settings, custom_fields, opts)
+ when is_list(opts) do
+ {cm, ie, ca} = view_opts(opts)
+ all_fields = get_offered_fields(custom_fields, cm, ie, ca)
+ default_set = default_visible_set(cm, ie, ca)
+ do_merge(user_selection, global_settings, custom_fields, all_fields, default_set)
+ end
+
+ defp do_merge(user_selection, global_settings, custom_fields, all_fields, default_set) do
+ global_visibility = get_global_visibility_map(global_settings, custom_fields, default_set)
Enum.reduce(all_fields, %{}, fn field, acc ->
field_string = field_to_string(field)
@@ -280,15 +475,15 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do
def get_visible_custom_fields(_), do: []
# Gets global visibility map from settings
- defp get_global_visibility_map(settings, custom_fields) do
- member_visibility = get_member_field_visibility_from_settings(settings)
+ defp get_global_visibility_map(settings, custom_fields, default_set) do
+ member_visibility = get_member_field_visibility_from_settings(settings, default_set)
custom_field_visibility = get_custom_field_visibility(custom_fields)
Map.merge(member_visibility, custom_field_visibility)
end
# Gets member field visibility from settings (domain fields from settings, pseudo fields default true)
- defp get_member_field_visibility_from_settings(settings) do
+ defp get_member_field_visibility_from_settings(settings, default_set) do
visibility_config =
VisibilityConfig.normalize(Map.get(settings, :member_field_visibility, %{}))
@@ -297,13 +492,13 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do
domain_map =
Enum.reduce(domain_fields, %{}, fn field, acc ->
field_string = Atom.to_string(field)
- default_visibility = MapSet.member?(@default_visible_fields, field)
+ default_visibility = MapSet.member?(default_set, field)
show_in_overview = Map.get(visibility_config, field, default_visibility)
Map.put(acc, field_string, show_in_overview)
end)
Enum.reduce(@pseudo_member_fields, domain_map, fn field, acc ->
- Map.put(acc, Atom.to_string(field), MapSet.member?(@default_visible_fields, field))
+ Map.put(acc, Atom.to_string(field), MapSet.member?(default_set, field))
end)
end
diff --git a/lib/mv_web/live/member_live/index/view_settings.ex b/lib/mv_web/live/member_live/index/view_settings.ex
new file mode 100644
index 00000000..d80a076e
--- /dev/null
+++ b/lib/mv_web/live/member_live/index/view_settings.ex
@@ -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
diff --git a/test/mv_web/components/sort_header_component_test.exs b/test/mv_web/components/sort_header_component_test.exs
index 955c3c3f..2ae3ce01 100644
--- a/test/mv_web/components/sort_header_component_test.exs
+++ b/test/mv_web/components/sort_header_component_test.exs
@@ -4,11 +4,25 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
# The curated default columns hide the individual name/address sort headers,
# so these component tests make every sortable field visible via ?fields=.
+ # The individual name/address constituents are only *offered* when the compact
+ # Member / Address view settings are off, so the session opts them out of the
+ # composite columns before selecting the fields via ?fields=.
@cols "first_name,email,street,house_number,postal_code,city,country,join_date"
+ # Disables the compact Member/Address composites so first_name/last_name/email
+ # and street/house_number/postal_code/city are offered as individual sortable
+ # columns (persisted per browser via the view-settings session key).
+ defp non_compact(conn) do
+ Plug.Conn.put_session(
+ conn,
+ "member_view_settings",
+ ~s({"compact_member":false,"compact_address":false})
+ )
+ end
+
describe "rendering" do
test "renders with correct attributes", %{conn: conn} do
- conn = conn_with_oidc_user(conn)
+ conn = conn |> conn_with_oidc_user() |> non_compact()
{:ok, view, _html} = live(conn, "/members?fields=#{@cols}")
# Test that the component renders with correct attributes
@@ -18,7 +32,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
end
test "renders all sortable headers", %{conn: conn} do
- conn = conn_with_oidc_user(conn)
+ conn = conn |> conn_with_oidc_user() |> non_compact()
{:ok, view, _html} = live(conn, "/members?fields=#{@cols}")
sortable_fields = [
@@ -38,7 +52,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
end
test "renders correct labels", %{conn: conn} do
- conn = conn_with_oidc_user(conn)
+ conn = conn |> conn_with_oidc_user() |> non_compact()
{:ok, view, _html} = live(conn, "/members?fields=#{@cols}")
# Test specific labels
@@ -50,7 +64,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
describe "sort icons" do
test "shows neutral icon for specific field when not sorted", %{conn: conn} do
- conn = conn_with_oidc_user(conn)
+ conn = conn |> conn_with_oidc_user() |> non_compact()
{:ok, view, _html} = live(conn, "/members?fields=#{@cols}")
# The neutral icon has the opcity class we can test for
@@ -62,7 +76,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
end
test "shows ascending icon for specific field when sorted ascending", %{conn: conn} do
- conn = conn_with_oidc_user(conn)
+ conn = conn |> conn_with_oidc_user() |> non_compact()
{:ok, view, html} =
live(conn, "/members?fields=#{@cols}&query=&sort_field=city&sort_order=asc")
@@ -85,7 +99,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
end
test "shows descending icon for specific field when sorted descending", %{conn: conn} do
- conn = conn_with_oidc_user(conn)
+ conn = conn |> conn_with_oidc_user() |> non_compact()
{:ok, _view, html} =
live(conn, "/members?fields=#{@cols}&query=&sort_field=email&sort_order=desc")
@@ -99,7 +113,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
end
test "multiple fields can have different icon states", %{conn: conn} do
- conn = conn_with_oidc_user(conn)
+ conn = conn |> conn_with_oidc_user() |> non_compact()
{:ok, view, _html} =
live(conn, "/members?fields=#{@cols}&query=&sort_field=city&sort_order=asc")
@@ -118,7 +132,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
end
test "icon state changes correctly when clicking different fields", %{conn: conn} do
- conn = conn_with_oidc_user(conn)
+ conn = conn |> conn_with_oidc_user() |> non_compact()
{:ok, view, _html} = live(conn, "/members?fields=#{@cols}")
# Start: all fields neutral except first name as default
@@ -146,7 +160,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
end
test "specific field shows correct icon for each sort state", %{conn: conn} do
- conn = conn_with_oidc_user(conn)
+ conn = conn |> conn_with_oidc_user() |> non_compact()
# Test EMAIL field specifically
{:ok, view, html_asc} =
@@ -167,7 +181,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
end
test "icon distribution shows exactly one active sort icon", %{conn: conn} do
- conn = conn_with_oidc_user(conn)
+ conn = conn |> conn_with_oidc_user() |> non_compact()
# Test neutral state - only one field should have active sort icon
{:ok, _view, html_neutral} = live(conn, "/members?fields=#{@cols}")
@@ -204,7 +218,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
describe "accessibility" do
test "sets aria-label correctly for unsorted state", %{conn: conn} do
- conn = conn_with_oidc_user(conn)
+ conn = conn |> conn_with_oidc_user() |> non_compact()
{:ok, view, _html} = live(conn, "/members?fields=#{@cols}")
# Check aria-label for unsorted state
@@ -212,7 +226,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
end
test "sets aria-label correctly for ascending sort", %{conn: conn} do
- conn = conn_with_oidc_user(conn)
+ conn = conn |> conn_with_oidc_user() |> non_compact()
{:ok, view, _html} =
live(conn, "/members?fields=#{@cols}&sort_field=first_name&sort_order=asc")
@@ -222,7 +236,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
end
test "sets aria-label correctly for descending sort", %{conn: conn} do
- conn = conn_with_oidc_user(conn)
+ conn = conn |> conn_with_oidc_user() |> non_compact()
{:ok, view, _html} =
live(conn, "/members?fields=#{@cols}&sort_field=first_name&sort_order=desc")
@@ -232,7 +246,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
end
test "includes tooltip with correct aria-label", %{conn: conn} do
- conn = conn_with_oidc_user(conn)
+ conn = conn |> conn_with_oidc_user() |> non_compact()
{:ok, view, _html} =
live(conn, "/members?fields=#{@cols}&sort_field=first_name&sort_order=asc")
@@ -243,7 +257,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
end
test "aria-labels work for all sortable fields", %{conn: conn} do
- conn = conn_with_oidc_user(conn)
+ conn = conn |> conn_with_oidc_user() |> non_compact()
{:ok, view, _html} = live(conn, "/members?fields=#{@cols}&sort_field=email&sort_order=desc")
# Test aria-labels for different fields
@@ -260,7 +274,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
describe "component behavior" do
test "clicking triggers sort event on parent LiveView", %{conn: conn} do
- conn = conn_with_oidc_user(conn)
+ conn = conn |> conn_with_oidc_user() |> non_compact()
{:ok, view, _html} = live(conn, "/members?fields=#{@cols}")
# Click on the first name sort header
@@ -273,7 +287,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
end
test "component handles different field types correctly", %{conn: conn} do
- conn = conn_with_oidc_user(conn)
+ conn = conn |> conn_with_oidc_user() |> non_compact()
{:ok, view, _html} = live(conn, "/members?fields=#{@cols}")
# Test that different field types render correctly
@@ -285,7 +299,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
describe "edge cases" do
test "handles invalid sort field gracefully", %{conn: conn} do
- conn = conn_with_oidc_user(conn)
+ conn = conn |> conn_with_oidc_user() |> non_compact()
{:ok, view, html} =
live(conn, "/members?fields=#{@cols}&sort_field=invalid_field&sort_order=asc")
@@ -296,7 +310,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
end
test "handles invalid sort order gracefully", %{conn: conn} do
- conn = conn_with_oidc_user(conn)
+ conn = conn |> conn_with_oidc_user() |> non_compact()
{:ok, view, html} =
live(conn, "/members?fields=#{@cols}&sort_field=first_name&sort_order=invalid")
@@ -307,7 +321,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
end
test "handles empty sort parameters", %{conn: conn} do
- conn = conn_with_oidc_user(conn)
+ conn = conn |> conn_with_oidc_user() |> non_compact()
{:ok, view, html} = live(conn, "/members?fields=#{@cols}&sort_field=&sort_order=")
# Should show neutral icons
@@ -318,7 +332,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
describe "icon state transitions" do
test "icon changes when sorting state changes", %{conn: conn} do
- conn = conn_with_oidc_user(conn)
+ conn = conn |> conn_with_oidc_user() |> non_compact()
{:ok, view, _html} = live(conn, "/members?fields=#{@cols}")
# Start with neutral state
@@ -334,7 +348,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
end
test "multiple fields can be tested for icon states", %{conn: conn} do
- conn = conn_with_oidc_user(conn)
+ conn = conn |> conn_with_oidc_user() |> non_compact()
{:ok, view, html} = live(conn, "/members?fields=#{@cols}&sort_field=email&sort_order=desc")
# Email should be active (descending)
diff --git a/test/mv_web/live/member_live/index/field_visibility_test.exs b/test/mv_web/live/member_live/index/field_visibility_test.exs
index 427cbead..728c6cf5 100644
--- a/test/mv_web/live/member_live/index/field_visibility_test.exs
+++ b/test/mv_web/live/member_live/index/field_visibility_test.exs
@@ -201,6 +201,56 @@ defmodule MvWeb.MemberLive.Index.FieldVisibilityTest do
end
end
+ describe "offered_member_fields/3 ordering (name group, then email, then address group, then rest)" do
+ test "compact member + compact address: Name, E-Mail, Adresse lead" do
+ result = FieldVisibility.offered_member_fields(true, false, true)
+
+ assert Enum.take(result, 3) == [:name, :email, :address]
+ # the composite constituents are not offered while their composite is on
+ refute :first_name in result
+ refute :street in result
+ # the Datenfelder-ordered rest trails the identity block
+ assert index_of(result, :address) < index_of(result, :join_date)
+ end
+
+ test "compact member with email folded in: no separate E-Mail column" do
+ result = FieldVisibility.offered_member_fields(true, true, true)
+
+ assert Enum.take(result, 2) == [:name, :address]
+ refute :email in result
+ end
+
+ test "non-compact member + compact address: Vorname/Nachname/E-Mail before Adresse" do
+ result = FieldVisibility.offered_member_fields(false, false, true)
+
+ # the reported bug: the composite address must not jump ahead of the
+ # expanded name constituents
+ assert Enum.take(result, 4) == [:first_name, :last_name, :email, :address]
+ assert index_of(result, :address) < index_of(result, :join_date)
+ end
+
+ test "non-compact member + non-compact address: name, email, then the address block, then rest" do
+ result = FieldVisibility.offered_member_fields(false, false, false)
+
+ assert Enum.take(result, 7) == [
+ :first_name,
+ :last_name,
+ :email,
+ :street,
+ :house_number,
+ :postal_code,
+ :city
+ ]
+
+ # join_date and later Datenfelder columns never precede the address block
+ assert index_of(result, :city) < index_of(result, :join_date)
+ refute :name in result
+ refute :address in result
+ end
+ end
+
+ defp index_of(list, elem), do: Enum.find_index(list, &(&1 == elem))
+
describe "get_visible_fields/1" do
test "returns only fields with true visibility" do
selection = %{
diff --git a/test/mv_web/live/member_live/index/view_settings_test.exs b/test/mv_web/live/member_live/index/view_settings_test.exs
new file mode 100644
index 00000000..44a86f96
--- /dev/null
+++ b/test/mv_web/live/member_live/index/view_settings_test.exs
@@ -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
diff --git a/test/mv_web/member_live/index_a11y_hardening_test.exs b/test/mv_web/member_live/index_a11y_hardening_test.exs
new file mode 100644
index 00000000..4b6434fc
--- /dev/null
+++ b/test/mv_web/member_live/index_a11y_hardening_test.exs
@@ -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
diff --git a/test/mv_web/member_live/index_column_manager_test.exs b/test/mv_web/member_live/index_column_manager_test.exs
new file mode 100644
index 00000000..723b8f2d
--- /dev/null
+++ b/test/mv_web/member_live/index_column_manager_test.exs
@@ -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
diff --git a/test/mv_web/member_live/index_default_columns_test.exs b/test/mv_web/member_live/index_default_columns_test.exs
index 04d3c993..5f5545d6 100644
--- a/test/mv_web/member_live/index_default_columns_test.exs
+++ b/test/mv_web/member_live/index_default_columns_test.exs
@@ -1,8 +1,9 @@
defmodule MvWeb.MemberLive.IndexDefaultColumnsTest do
@moduledoc """
§1.2 — With no persisted column selection, exactly the curated default columns
- are visible: selection checkbox, Name (name + email), Address (composite),
- fee type, fee status, groups, join date.
+ are visible: selection checkbox, Name (composite, no in-cell email line),
+ E-Mail (its own column while the compact Member field keeps the email line off),
+ Address (composite), fee type, fee status, groups, join date.
"""
use MvWeb.ConnCase, async: false
@@ -34,6 +35,8 @@ defmodule MvWeb.MemberLive.IndexDefaultColumnsTest do
# Composite Name + Address cells.
assert has_element?(view, "[data-testid='member-name']")
assert has_element?(view, "[data-testid='member-address']")
+ # E-Mail is its own column by default (compact Member field, email line off).
+ assert has_element?(view, "[data-testid='email']")
# Fee type, fee status, groups, join date headers.
assert has_element?(view, "[data-testid='membership_fee_type']")
assert has_element?(view, "[data-testid='join_date']")
@@ -44,7 +47,9 @@ defmodule MvWeb.MemberLive.IndexDefaultColumnsTest do
test "the individual name/address sub-fields are hidden by default", %{conn: conn} do
{:ok, view, _html} = live(conn, ~p"/members")
- for field <- ~w(first_name last_name email city street house_number postal_code country) do
+ # The composite Name/Address cells replace their constituents by default.
+ # E-Mail is excluded here: it is a default-visible column of its own.
+ for field <- ~w(first_name last_name city street house_number postal_code country) do
refute has_element?(view, "[data-testid='#{field}']")
end
end
diff --git a/test/mv_web/member_live/index_density_test.exs b/test/mv_web/member_live/index_density_test.exs
new file mode 100644
index 00000000..dac7c3a6
--- /dev/null
+++ b/test/mv_web/member_live/index_density_test.exs
@@ -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
diff --git a/test/mv_web/member_live/index_field_visibility_test.exs b/test/mv_web/member_live/index_field_visibility_test.exs
index 71744f83..fc1e6fa4 100644
--- a/test/mv_web/member_live/index_field_visibility_test.exs
+++ b/test/mv_web/member_live/index_field_visibility_test.exs
@@ -22,6 +22,17 @@ defmodule MvWeb.MemberLive.IndexFieldVisibilityTest do
require Ash.Query
+ # Disables the compact Member/Address composites (per-browser view setting) so
+ # the individual constituent columns (first_name/last_name/email, street/…) are
+ # offered by the column manager and available via ?fields=.
+ defp non_compact(conn) do
+ Plug.Conn.put_session(
+ conn,
+ "member_view_settings",
+ ~s({"compact_member":false,"compact_address":false})
+ )
+ end
+
setup do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
@@ -112,7 +123,9 @@ defmodule MvWeb.MemberLive.IndexFieldVisibilityTest do
end
test "displays all member fields in dropdown", %{conn: conn} do
- conn = conn_with_oidc_user(conn)
+ # The individual name/address constituents are only offered when the
+ # compact Member/Address composites are off, so opt out of them first.
+ conn = conn |> conn_with_oidc_user() |> non_compact()
{:ok, view, _html} = live(conn, "/members")
# Open dropdown
@@ -148,9 +161,9 @@ defmodule MvWeb.MemberLive.IndexFieldVisibilityTest do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
- # The curated Name column carries the email, so it is visible initially.
+ # The curated Name column carries the member names, so they are visible.
html = render(view)
- assert html =~ "alice@example.com"
+ assert html =~ "Anderson"
# Open dropdown and hide the Name column
view
@@ -161,10 +174,10 @@ defmodule MvWeb.MemberLive.IndexFieldVisibilityTest do
|> element("button[phx-click='select_item'][phx-value-item='name']")
|> render_click()
- # The Name column (and the email it carries) is no longer visible
+ # The Name column (and the names it carries) is no longer visible
html = render(view)
- refute html =~ "alice@example.com"
- refute html =~ "bob@example.com"
+ refute html =~ "Anderson"
+ refute html =~ "Brown"
end
test "hiding custom field removes it from display", %{conn: conn, custom_field: custom_field} do
@@ -298,8 +311,8 @@ defmodule MvWeb.MemberLive.IndexFieldVisibilityTest do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members")
- # All fields should be visible by default
- assert html =~ "alice@example.com"
+ # The curated Name and Address columns are visible by default.
+ assert html =~ "Anderson"
assert html =~ "Main St"
end
@@ -309,7 +322,7 @@ defmodule MvWeb.MemberLive.IndexFieldVisibilityTest do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
- # Hide the curated Name column (which carries the email) via dropdown
+ # Hide the curated Name column (which carries the names) via dropdown
view
|> element("button[aria-controls='field-visibility-menu']")
|> render_click()
@@ -319,7 +332,7 @@ defmodule MvWeb.MemberLive.IndexFieldVisibilityTest do
|> render_click()
html = render(view)
- refute html =~ "alice@example.com"
+ refute html =~ "Anderson"
end
end
@@ -328,16 +341,16 @@ defmodule MvWeb.MemberLive.IndexFieldVisibilityTest do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members?fields=")
- # Should fall back to global settings
- assert html =~ "alice@example.com"
+ # Should fall back to global settings (curated Name column visible)
+ assert html =~ "Anderson"
end
test "handles invalid field names in URL", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members?fields=invalid_field,another_invalid")
- # Should ignore invalid fields and use defaults
- assert html =~ "alice@example.com"
+ # Should ignore invalid fields and use defaults (curated Name column visible)
+ assert html =~ "Anderson"
end
test "handles custom field that doesn't exist", %{conn: conn} do
@@ -429,9 +442,9 @@ defmodule MvWeb.MemberLive.IndexFieldVisibilityTest do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
- # The curated Name column carries the email, so it is visible initially.
+ # The curated Name column carries the names, so they are visible initially.
html = render(view)
- assert html =~ "alice@example.com"
+ assert html =~ "Anderson"
# Open dropdown
view
@@ -443,9 +456,9 @@ defmodule MvWeb.MemberLive.IndexFieldVisibilityTest do
|> element("button[phx-click='select_item'][phx-value-item='name']")
|> render_keydown(%{key: "Enter"})
- # The Name column (and the email it carries) is no longer visible
+ # The Name column (and the names it carries) is no longer visible
html = render(view)
- refute html =~ "alice@example.com"
+ refute html =~ "Anderson"
end
end
end
diff --git a/test/mv_web/member_live/index_groups_url_params_test.exs b/test/mv_web/member_live/index_groups_url_params_test.exs
index 8b92cb90..3de60923 100644
--- a/test/mv_web/member_live/index_groups_url_params_test.exs
+++ b/test/mv_web/member_live/index_groups_url_params_test.exs
@@ -20,6 +20,16 @@ defmodule MvWeb.MemberLive.IndexGroupsUrlParamsTest do
require Ash.Query
+ # Disables the compact Member composite (per-browser view setting) so the
+ # individual first_name column is offered and selectable via ?fields=.
+ defp non_compact(conn) do
+ Plug.Conn.put_session(
+ conn,
+ "member_view_settings",
+ ~s({"compact_member":false,"compact_address":false})
+ )
+ end
+
setup do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
@@ -122,7 +132,7 @@ defmodule MvWeb.MemberLive.IndexGroupsUrlParamsTest do
member1: member1,
group1: group1
} do
- conn = conn_with_oidc_user(conn)
+ conn = conn |> conn_with_oidc_user() |> non_compact()
{:ok, view, html} =
live(
diff --git a/test/mv_web/member_live/index_member_fields_display_test.exs b/test/mv_web/member_live/index_member_fields_display_test.exs
index 6c222276..7b1785bf 100644
--- a/test/mv_web/member_live/index_member_fields_display_test.exs
+++ b/test/mv_web/member_live/index_member_fields_display_test.exs
@@ -38,6 +38,9 @@ defmodule MvWeb.MemberLive.IndexMemberFieldsDisplayTest do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members")
+ # The composite Member cell carries first + last name; the in-cell email
+ # line stays off by default, but the email is surfaced in its own column
+ # (compact Member field, include-email off).
for m <- [m1, m2], field <- [m.first_name, m.last_name, m.email] do
assert html =~ field
end
diff --git a/test/mv_web/member_live/index_test.exs b/test/mv_web/member_live/index_test.exs
index e1de77a8..14d76a82 100644
--- a/test/mv_web/member_live/index_test.exs
+++ b/test/mv_web/member_live/index_test.exs
@@ -13,6 +13,19 @@ defmodule MvWeb.MemberLive.IndexTest do
alias Mv.Membership.CustomFieldValue
alias MvWeb.MemberLive.Index, as: MemberIndex
+ # Disables the compact Member/Address composites (per-browser view setting) so
+ # the individual constituent columns (first_name/last_name, street/…) are
+ # offered by the column manager and selectable via ?fields=. The composite
+ # E-Mail column is offered even in compact mode, so email-only tests do not
+ # need this.
+ defp non_compact(conn) do
+ Plug.Conn.put_session(
+ conn,
+ "member_view_settings",
+ ~s({"compact_member":false,"compact_address":false})
+ )
+ end
+
describe "desktop layout: scroll container and sticky table header" do
@describetag :ui
@@ -155,9 +168,11 @@ defmodule MvWeb.MemberLive.IndexTest do
describe "sorting integration" do
@describetag :ui
# The curated default columns no longer expose the individual name/address
- # sort headers, so these tests make the relevant column visible via ?fields=.
- # That puts a `fields` param on every push_patch, hence the relaxed
- # substring assertions instead of exact patch strings.
+ # sort headers. The composite E-Mail column is offered even in compact mode
+ # (so email-only tests just add it via ?fields=), but the name/address
+ # constituents are only offered once their composite view setting is off —
+ # those tests additionally opt out via `non_compact/1`. Either way a `fields`
+ # param rides on every push_patch, hence the relaxed substring assertions.
test "clicking a column header toggles sort order and updates the URL", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members?fields=email")
@@ -183,7 +198,7 @@ defmodule MvWeb.MemberLive.IndexTest do
end
test "clicking different column header resets order to ascending", %{conn: conn} do
- conn = conn_with_oidc_user(conn)
+ conn = conn |> conn_with_oidc_user() |> non_compact()
{:ok, view, _html} =
live(conn, "/members?fields=first_name,email&sort_field=email&sort_order=desc")
@@ -199,7 +214,7 @@ defmodule MvWeb.MemberLive.IndexTest do
end
test "all sortable columns work correctly", %{conn: conn} do
- conn = conn_with_oidc_user(conn)
+ conn = conn |> conn_with_oidc_user() |> non_compact()
{:ok, view, _html} =
live(
@@ -275,7 +290,7 @@ defmodule MvWeb.MemberLive.IndexTest do
end
test "handle_params handles invalid sort field gracefully", %{conn: conn} do
- conn = conn_with_oidc_user(conn)
+ conn = conn |> conn_with_oidc_user() |> non_compact()
{:ok, view, _html} =
live(conn, "/members?fields=first_name&query=&sort_field=invalid_field&sort_order=asc")
diff --git a/test/mv_web/member_live/index_view_settings_test.exs b/test/mv_web/member_live/index_view_settings_test.exs
new file mode 100644
index 00000000..483a8214
--- /dev/null
+++ b/test/mv_web/member_live/index_view_settings_test.exs
@@ -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