diff --git a/assets/css/app.css b/assets/css/app.css
index 611e9ad1..37091909 100644
--- a/assets/css/app.css
+++ b/assets/css/app.css
@@ -678,6 +678,78 @@
/* This file is for your main application CSS */
+/* ============================================
+ Table header density scaling
+ ============================================ */
+
+/*
+ * DaisyUI table size modifiers (table-xs, table-sm, etc.) scope their font-size
+ * rule to ":not(thead, tfoot) tr", so the header row stays at the base 0.875 rem
+ * regardless of the chosen density. We extend each modifier to thead so the
+ * header scales together with the body.
+ */
+.table.table-xs thead tr { font-size: 0.6875rem; }
+.table.table-sm thead tr { font-size: 0.75rem; }
+.table.table-lg thead tr { font-size: 1.125rem; }
+.table.table-xl thead tr { font-size: 1.375rem; }
+
+/*
+ * Density-tracking badges and sort icons in the member overview.
+ *
+ * The member table renders its rows via LiveView streams, so toggling density
+ * only swaps the
size class (table-xs <-> table-md); it does NOT
+ * re-render the streamed rows. A per-row `size=` conditional therefore bakes a
+ * stale badge size into the streamed DOM. To make badges (and, for visual
+ * consistency, the header sort glyphs) follow the density toggle without any
+ * row re-render, their size is driven purely from the table size class here.
+ *
+ * Badge values mirror daisyUI's own .badge-xs / .badge-md (font-size, --size
+ * which the base .badge uses for height, and padding-inline). Scoped to the
+ * member overview via #members-keyboard so unrelated badges are unaffected.
+ */
+#members-keyboard .table.table-xs .badge {
+ --size: calc(var(--size-selector, 0.25rem) * 4);
+ font-size: 0.625rem;
+ padding-inline: calc(0.25rem * 2 - var(--border));
+}
+#members-keyboard .table.table-md .badge {
+ --size: calc(var(--size-selector, 0.25rem) * 6);
+ font-size: 0.875rem;
+ padding-inline: calc(0.25rem * 3 - var(--border));
+}
+
+/* Sort glyphs scale with the header text (11px compact / 14px comfortable). */
+#members-keyboard .table.table-md thead .sort-icon {
+ width: 1.5rem;
+ height: 1.5rem;
+}
+#members-keyboard .table.table-xs thead .sort-icon {
+ width: 1.125rem;
+ height: 1.125rem;
+}
+
+/*
+ * Infinite-scroll loading row.
+ *
+ * The loading indicator must read like a full, accented data row rather than a
+ * narrow pinned bar. The accent (base-200 background + a top separator) and the
+ * padding live on the full-width footer cell, which spans every column via
+ * colspan, so the accent is visible edge-to-edge even while the table is scrolled
+ * horizontally. The spinner + label inside stay pinned to the visible viewport
+ * width (StickyViewportWidth hook) and transparent, so they remain centered on
+ * horizontal scroll while the cell accent shows through behind them.
+ *
+ * The row height tracks the active density (table-xs compact / table-md
+ * comfortable) so it matches a normal data row in each mode.
+ */
+#members-footer > tr > td {
+ background-color: var(--color-base-200);
+ border-top: 1px solid var(--color-base-300);
+ padding: 0;
+}
+.table.table-xs #members-loading-bar { min-height: 3.375rem; }
+.table.table-md #members-loading-bar { min-height: 4.1875rem; }
+
/* ============================================
SortableList: drag-and-drop table rows
============================================ */
@@ -773,3 +845,115 @@
[data-sticky-first-col-rows="true"] .table.table-zebra tbody tr input.checkbox:focus:not(:focus-visible) {
outline: none;
}
+
+/*
+ * Sticky checkbox column: border-collapse: separate avoids the Chromium artifact
+ * where a collapsed row border is repainted (a spurious 1px line) under a
+ * position: sticky cell. The row separators are moved onto the cells so they
+ * still render in the separate model (matching DaisyUI's faint divider color).
+ */
+[data-sticky-first-col-rows="true"] .table.table-zebra {
+ border-collapse: separate;
+ border-spacing: 0;
+}
+[data-sticky-first-col-rows="true"]
+ .table.table-zebra
+ :where(thead tr, tbody tr:not(:last-child))
+ > :where(th, td) {
+ border-bottom: var(--border) solid color-mix(in oklch, var(--color-base-content) 5%, #0000);
+}
+
+/*
+ * Horizontal-scroll fade for the pinned checkbox column: instead of hiding the
+ * scrolled-away content behind a hard opaque block, a gradient on the right edge
+ * of the sticky cell fades the incoming content, signalling there is more behind
+ * it. overflow: visible lets the ::after gradient extend past the cell; the cell
+ * keeps its own (zebra) background so the checkbox stays legible.
+ */
+[data-sticky-first-col-rows="true"] .table.table-zebra td.sticky-first-col-cell {
+ overflow: visible;
+}
+[data-sticky-first-col-rows="true"] .table.table-zebra td.sticky-first-col-cell::after,
+[data-sticky-first-col-rows="true"] .table.table-zebra thead th.sticky-first-col-th::after {
+ content: "";
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ left: 100%;
+ width: 1.25rem;
+ pointer-events: none;
+}
+[data-sticky-first-col-rows="true"]
+ .table.table-zebra
+ tbody
+ tr:nth-child(odd)
+ > td.sticky-first-col-cell::after {
+ background: linear-gradient(to right, var(--color-base-100), transparent);
+}
+[data-sticky-first-col-rows="true"]
+ .table.table-zebra
+ tbody
+ tr:nth-child(even)
+ > td.sticky-first-col-cell::after {
+ background: linear-gradient(to right, var(--color-base-200), transparent);
+}
+[data-sticky-first-col-rows="true"]
+ .table.table-zebra
+ tbody
+ tr[data-row-interactive="true"]:is(:hover, :has(:focus-visible))
+ > td.sticky-first-col-cell::after {
+ background: linear-gradient(to right, var(--color-base-300), transparent);
+}
+[data-sticky-first-col-rows="true"] .table.table-zebra thead th.sticky-first-col-th::after {
+ background: linear-gradient(to right, var(--color-base-100), transparent);
+}
+/* Compact density: narrower fade so the gradient does not visually reach into the
+ checkbox column's own content area in the tighter table-xs cell. */
+[data-sticky-first-col-rows="true"] .table.table-zebra.table-xs td.sticky-first-col-cell::after,
+[data-sticky-first-col-rows="true"] .table.table-zebra.table-xs thead th.sticky-first-col-th::after {
+ width: 0.5rem;
+}
+
+/*
+ * Sort-header tooltip. Rendered via the native Popover API (see the SortTooltip
+ * JS hook), so it lives in the browser TOP LAYER: it is not clipped by the
+ * members table's overflow (overflow-x:auto forces overflow-y:auto, which used
+ * to clip a CSS pseudo-tooltip below the header) and paints above the sticky
+ * header, neighbor cells, and the pinned checkbox column's fade — without any
+ * z-index hacks. This block only resets the popover UA defaults and gives it a
+ * small daisyUI-flavoured tooltip look; the JS hook sets top/left each show.
+ */
+.sort-tooltip {
+ position: fixed;
+ inset: auto;
+ margin: 0;
+ border: 0;
+ padding: 0.25rem 0.5rem;
+ width: max-content;
+ max-width: 16rem;
+ overflow: visible;
+ background: var(--color-neutral, oklch(0.2 0 0));
+ color: var(--color-neutral-content, oklch(0.98 0 0));
+ font-size: 0.75rem;
+ line-height: 1rem;
+ border-radius: 0.25rem;
+ box-shadow: 0 1px 3px rgb(0 0 0 / 0.3);
+ pointer-events: none;
+}
+
+/*
+ * Vertically center the row checkbox in the sticky first column using the
+ * table-native vertical-align: middle. This keeps display: table-cell intact so
+ * the cell background fills the full row height (no white gap at the bottom).
+ * The fieldset margin-bottom reset removes the mb-2 from the shared input
+ * component so no residual margin shifts the checkbox off-center.
+ */
+[data-sticky-first-col-rows="true"] .table.table-zebra td.sticky-first-col-cell,
+[data-sticky-first-col-rows="true"] .table.table-zebra thead th.sticky-first-col-th {
+ vertical-align: middle;
+}
+[data-sticky-first-col-rows="true"] .table.table-zebra td.sticky-first-col-cell fieldset,
+[data-sticky-first-col-rows="true"] .table.table-zebra thead th.sticky-first-col-th fieldset {
+ margin-bottom: 0;
+}
+
diff --git a/lib/mv_web/components/core_components.ex b/lib/mv_web/components/core_components.ex
index d01b3d63..7af829a8 100644
--- a/lib/mv_web/components/core_components.ex
+++ b/lib/mv_web/components/core_components.ex
@@ -953,6 +953,11 @@ defmodule MvWeb.CoreComponents do
doc:
"when true, first header/body column gets sticky left positioning to keep selection controls visible"
+ attr :sticky_second_col, :boolean,
+ default: false,
+ doc:
+ "when true, the second column is also pinned left (e.g. an identifier column kept visible while scrolling horizontally)"
+
attr :viewport_bottom, :any,
default: nil,
doc:
@@ -1018,7 +1023,9 @@ defmodule MvWeb.CoreComponents do
:for={{col, col_idx} <- Enum.with_index(@col)}
class={[
table_th_class(col, @sticky_header),
- @sticky_first_col && col_idx == 0 && "sticky left-0 z-30 bg-base-100"
+ @sticky_first_col && col_idx == 0 &&
+ "sticky-first-col-th sticky left-0 z-30 bg-base-100",
+ @sticky_second_col && col_idx == 1 && "sticky left-12 z-30 bg-base-100"
]}
aria-sort={table_th_aria_sort(col, @sort_field, @sort_order)}
>
@@ -1079,6 +1086,13 @@ defmodule MvWeb.CoreComponents do
classes
end
+ classes =
+ if @sticky_second_col && col_idx == 1 do
+ ["sticky left-12 z-20 bg-base-100" | classes]
+ else
+ classes
+ end
+
classes =
if col_class == nil || (col_class && !String.contains?(col_class, "text-center")) do
["truncate" | classes]
diff --git a/lib/mv_web/live/components/search_bar_component.ex b/lib/mv_web/live/components/search_bar_component.ex
index ac03a637..d31c563e 100644
--- a/lib/mv_web/live/components/search_bar_component.ex
+++ b/lib/mv_web/live/components/search_bar_component.ex
@@ -49,6 +49,17 @@ defmodule MvWeb.Components.SearchBarComponent do
phx-target={@myself}
phx-debounce="300"
/>
+
+ <.icon name="hero-x-mark" class="h-[1em]" />
+
"""
@@ -61,4 +72,10 @@ defmodule MvWeb.Components.SearchBarComponent do
send(self(), {:search_changed, q})
{:noreply, assign(socket, :query, q)}
end
+
+ # Clears the query and resets the result set to the unfiltered list (§1.3).
+ def handle_event("clear_search", _params, socket) do
+ send(self(), {:search_changed, ""})
+ {:noreply, assign(socket, :query, "")}
+ end
end
diff --git a/lib/mv_web/live/member_live/index.html.heex b/lib/mv_web/live/member_live/index.html.heex
index d9049146..55bf93f5 100644
--- a/lib/mv_web/live/member_live/index.html.heex
+++ b/lib/mv_web/live/member_live/index.html.heex
@@ -149,6 +149,48 @@
role="checkbox"
/>
+ <:col
+ :let={member}
+ :if={:name in @member_fields_visible}
+ label={gettext("Name")}
+ >
+ <% full_name =
+ [member.first_name, member.last_name]
+ |> Enum.reject(&(&1 in [nil, ""]))
+ |> Enum.join(" ") %>
+
+
+ {full_name}
+
+
+ {member.email}
+
+
+
+ <: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")}>
+
+
+
<:col
:let={member}
:if={:first_name in @member_fields_visible}
@@ -280,7 +322,9 @@
"""
}
>
- {member.city}
+ <.maybe_value value={member.city} empty_sr_text={gettext("Not specified")}>
+ {member.city}
+
<:col
:let={member}
@@ -298,7 +342,9 @@
"""
}
>
- {member.street}
+ <.maybe_value value={member.street} empty_sr_text={gettext("Not specified")}>
+ {member.street}
+
<:col
:let={member}
@@ -316,7 +362,9 @@
"""
}
>
- {member.house_number}
+ <.maybe_value value={member.house_number} empty_sr_text={gettext("Not specified")}>
+ {member.house_number}
+
<:col
:let={member}
@@ -334,7 +382,9 @@
"""
}
>
- {member.postal_code}
+ <.maybe_value value={member.postal_code} empty_sr_text={gettext("Not specified")}>
+ {member.postal_code}
+
<:col
:let={member}
@@ -407,15 +457,17 @@
}
>
<.maybe_value value={member.groups} empty_sr_text={gettext("No group assignment")}>
- <%= for group <- (member.groups || []) do %>
- <.badge
- variant="primary"
- style="outline"
- aria-label={gettext("Member of group %{name}", name: group.name)}
- >
- {group.name}
-
- <% end %>
+
+ <%= for group <- (member.groups || []) do %>
+ <.badge
+ variant="primary"
+ style="outline"
+ aria-label={gettext("Member of group %{name}", name: group.name)}
+ >
+ {group.name}
+
+ <% end %>
+
<:action :let={member}>
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 52ebe86f..8db026d5 100644
--- a/lib/mv_web/live/member_live/index/field_visibility.ex
+++ b/lib/mv_web/live/member_live/index/field_visibility.ex
@@ -29,7 +29,28 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do
# Single UI key for "Membership Fee Status"; only this appears in the dropdown.
# Groups and membership_fee_type are also pseudo fields (not in member_fields(), displayed in the table).
- @pseudo_member_fields [:membership_fee_status, :membership_fee_type, :groups]
+ # :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]
+
+ # 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
+ # (the individual name/address sub-fields, notes, dates other than join date)
+ # defaults to hidden so the table fits common desktop widths.
+ @default_visible_fields MapSet.new([
+ :name,
+ :address,
+ :membership_fee_type,
+ :membership_fee_status,
+ :groups,
+ :join_date
+ ])
+
+ @doc """
+ The curated default-visible columns (member-field atoms) for a first visit.
+ """
+ @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
@@ -276,13 +297,13 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do
domain_map =
Enum.reduce(domain_fields, %{}, fn field, acc ->
field_string = Atom.to_string(field)
- default_visibility = if field == :exit_date, do: false, else: true
+ default_visibility = MapSet.member?(@default_visible_fields, 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), true)
+ Map.put(acc, Atom.to_string(field), MapSet.member?(@default_visible_fields, field))
end)
end
diff --git a/lib/mv_web/translations/member_fields.ex b/lib/mv_web/translations/member_fields.ex
index fa7abff8..9fd620c3 100644
--- a/lib/mv_web/translations/member_fields.ex
+++ b/lib/mv_web/translations/member_fields.ex
@@ -32,6 +32,8 @@ defmodule MvWeb.Translations.MemberFields do
def label(:membership_fee_status), do: gettext("Membership Fee Status")
def label(:membership_fee_type), do: gettext("Fee Type")
def label(:groups), do: gettext("Groups")
+ def label(:name), do: gettext("Name")
+ def label(:address), do: gettext("Address")
# Fallback for unknown fields
def label(field) do
diff --git a/test/mv_web/components/sort_header_component_test.exs b/test/mv_web/components/sort_header_component_test.exs
index 0a7bf806..955c3c3f 100644
--- a/test/mv_web/components/sort_header_component_test.exs
+++ b/test/mv_web/components/sort_header_component_test.exs
@@ -2,10 +2,14 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
use MvWeb.ConnCase, async: true
import Phoenix.LiveViewTest
+ # The curated default columns hide the individual name/address sort headers,
+ # so these component tests make every sortable field visible via ?fields=.
+ @cols "first_name,email,street,house_number,postal_code,city,country,join_date"
+
describe "rendering" do
test "renders with correct attributes", %{conn: conn} do
conn = conn_with_oidc_user(conn)
- {:ok, view, _html} = live(conn, "/members")
+ {:ok, view, _html} = live(conn, "/members?fields=#{@cols}")
# Test that the component renders with correct attributes
assert has_element?(view, "[data-testid='first_name']")
@@ -15,7 +19,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
test "renders all sortable headers", %{conn: conn} do
conn = conn_with_oidc_user(conn)
- {:ok, view, _html} = live(conn, "/members")
+ {:ok, view, _html} = live(conn, "/members?fields=#{@cols}")
sortable_fields = [
:first_name,
@@ -35,7 +39,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
test "renders correct labels", %{conn: conn} do
conn = conn_with_oidc_user(conn)
- {:ok, view, _html} = live(conn, "/members")
+ {:ok, view, _html} = live(conn, "/members?fields=#{@cols}")
# Test specific labels
assert has_element?(view, "button[phx-value-field='first_name']", "First name")
@@ -47,7 +51,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)
- {:ok, view, _html} = live(conn, "/members")
+ {:ok, view, _html} = live(conn, "/members?fields=#{@cols}")
# The neutral icon has the opcity class we can test for
# Test that EMAIL field specifically shows neutral icon
@@ -59,7 +63,9 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
test "shows ascending icon for specific field when sorted ascending", %{conn: conn} do
conn = conn_with_oidc_user(conn)
- {:ok, view, html} = live(conn, "/members?query=&sort_field=city&sort_order=asc")
+
+ {:ok, view, html} =
+ live(conn, "/members?fields=#{@cols}&query=&sort_field=city&sort_order=asc")
# Test that FIRST_NAME field specifically shows ascending icon
# Test CSS classes - no opacity for active state
@@ -80,7 +86,9 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
test "shows descending icon for specific field when sorted descending", %{conn: conn} do
conn = conn_with_oidc_user(conn)
- {:ok, _view, html} = live(conn, "/members?query=&sort_field=email&sort_order=desc")
+
+ {:ok, _view, html} =
+ live(conn, "/members?fields=#{@cols}&query=&sort_field=email&sort_order=desc")
# Count occurrences to ensure only one descending sort icon. Dropdown
# triggers carry their own trailing "hero-chevron-down size-4" chevron, so
@@ -92,7 +100,9 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
test "multiple fields can have different icon states", %{conn: conn} do
conn = conn_with_oidc_user(conn)
- {:ok, view, _html} = live(conn, "/members?query=&sort_field=city&sort_order=asc")
+
+ {:ok, view, _html} =
+ live(conn, "/members?fields=#{@cols}&query=&sort_field=city&sort_order=asc")
# CITY field should be active (ascending)
refute has_element?(view, "[data-testid='city'] .opacity-40")
@@ -109,7 +119,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
test "icon state changes correctly when clicking different fields", %{conn: conn} do
conn = conn_with_oidc_user(conn)
- {:ok, view, _html} = live(conn, "/members")
+ {:ok, view, _html} = live(conn, "/members?fields=#{@cols}")
# Start: all fields neutral except first name as default
assert has_element?(view, "[data-testid='city'] .opacity-40")
@@ -139,15 +149,19 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
conn = conn_with_oidc_user(conn)
# Test EMAIL field specifically
- {:ok, view, html_asc} = live(conn, "/members?sort_field=email&sort_order=asc")
+ {:ok, view, html_asc} =
+ live(conn, "/members?fields=#{@cols}&sort_field=email&sort_order=asc")
+
assert html_asc =~ "hero-chevron-up"
refute has_element?(view, "[data-testid='email'] .opacity-40")
- {:ok, view, html_desc} = live(conn, "/members?sort_field=email&sort_order=desc")
+ {:ok, view, html_desc} =
+ live(conn, "/members?fields=#{@cols}&sort_field=email&sort_order=desc")
+
assert html_desc =~ "hero-chevron-down"
refute has_element?(view, "[data-testid='email'] .opacity-40")
- {:ok, view, html_neutral} = live(conn, "/members")
+ {:ok, view, html_neutral} = live(conn, "/members?fields=#{@cols}")
assert html_neutral =~ "hero-chevron-up-down"
assert has_element?(view, "[data-testid='email'] .opacity-40")
end
@@ -156,7 +170,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
conn = conn_with_oidc_user(conn)
# Test neutral state - only one field should have active sort icon
- {:ok, _view, html_neutral} = live(conn, "/members")
+ {:ok, _view, html_neutral} = live(conn, "/members?fields=#{@cols}")
# Count active icons (should be exactly 1 - ascending for default sort field)
up_count = html_neutral |> String.split("hero-chevron-up ") |> length() |> Kernel.-(1)
@@ -166,7 +180,8 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
assert down_count == 0, "Expected 0 descending icons, got #{down_count}"
# Test descending state
- {:ok, _view, html_desc} = live(conn, "/members?sort_field=first_name&sort_order=desc")
+ {:ok, _view, html_desc} =
+ live(conn, "/members?fields=#{@cols}&sort_field=first_name&sort_order=desc")
up_count = html_desc |> String.split("hero-chevron-up ") |> length() |> Kernel.-(1)
down_count = active_sort_down_count(html_desc)
@@ -190,7 +205,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)
- {:ok, view, _html} = live(conn, "/members")
+ {:ok, view, _html} = live(conn, "/members?fields=#{@cols}")
# Check aria-label for unsorted state
assert has_element?(view, "button[phx-value-field='city'][aria-label='Click to sort']")
@@ -198,7 +213,9 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
test "sets aria-label correctly for ascending sort", %{conn: conn} do
conn = conn_with_oidc_user(conn)
- {:ok, view, _html} = live(conn, "/members?sort_field=first_name&sort_order=asc")
+
+ {:ok, view, _html} =
+ live(conn, "/members?fields=#{@cols}&sort_field=first_name&sort_order=asc")
# Check aria-label for ascending sort
assert has_element?(view, "button[phx-value-field='first_name'][aria-label='ascending']")
@@ -206,7 +223,9 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
test "sets aria-label correctly for descending sort", %{conn: conn} do
conn = conn_with_oidc_user(conn)
- {:ok, view, _html} = live(conn, "/members?sort_field=first_name&sort_order=desc")
+
+ {:ok, view, _html} =
+ live(conn, "/members?fields=#{@cols}&sort_field=first_name&sort_order=desc")
# Check aria-label for descending sort
assert has_element?(view, "button[phx-value-field='first_name'][aria-label='descending']")
@@ -214,7 +233,9 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
test "includes tooltip with correct aria-label", %{conn: conn} do
conn = conn_with_oidc_user(conn)
- {:ok, view, _html} = live(conn, "/members?sort_field=first_name&sort_order=asc")
+
+ {:ok, view, _html} =
+ live(conn, "/members?fields=#{@cols}&sort_field=first_name&sort_order=asc")
# Check that tooltip div exists with correct data-tip
assert has_element?(view, "[data-testid='first_name']")
@@ -223,7 +244,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
test "aria-labels work for all sortable fields", %{conn: conn} do
conn = conn_with_oidc_user(conn)
- {:ok, view, _html} = live(conn, "/members?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
assert has_element?(view, "button[phx-value-field='email'][aria-label='descending']")
@@ -240,7 +261,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)
- {:ok, view, _html} = live(conn, "/members")
+ {:ok, view, _html} = live(conn, "/members?fields=#{@cols}")
# Click on the first name sort header
view
@@ -253,7 +274,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
test "component handles different field types correctly", %{conn: conn} do
conn = conn_with_oidc_user(conn)
- {:ok, view, _html} = live(conn, "/members")
+ {:ok, view, _html} = live(conn, "/members?fields=#{@cols}")
# Test that different field types render correctly
assert has_element?(view, "button[phx-value-field='first_name']")
@@ -265,7 +286,9 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
describe "edge cases" do
test "handles invalid sort field gracefully", %{conn: conn} do
conn = conn_with_oidc_user(conn)
- {:ok, view, html} = live(conn, "/members?sort_field=invalid_field&sort_order=asc")
+
+ {:ok, view, html} =
+ live(conn, "/members?fields=#{@cols}&sort_field=invalid_field&sort_order=asc")
# Should not crash and should default sorting for first name
assert html =~ "hero-chevron-up-down"
@@ -274,7 +297,9 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
test "handles invalid sort order gracefully", %{conn: conn} do
conn = conn_with_oidc_user(conn)
- {:ok, view, html} = live(conn, "/members?sort_field=first_name&sort_order=invalid")
+
+ {:ok, view, html} =
+ live(conn, "/members?fields=#{@cols}&sort_field=first_name&sort_order=invalid")
# Should default to ascending
assert html =~ "hero-chevron-up"
@@ -283,7 +308,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
test "handles empty sort parameters", %{conn: conn} do
conn = conn_with_oidc_user(conn)
- {:ok, view, html} = live(conn, "/members?sort_field=&sort_order=")
+ {:ok, view, html} = live(conn, "/members?fields=#{@cols}&sort_field=&sort_order=")
# Should show neutral icons
assert html =~ "hero-chevron-up-down"
@@ -294,7 +319,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)
- {:ok, view, _html} = live(conn, "/members")
+ {:ok, view, _html} = live(conn, "/members?fields=#{@cols}")
# Start with neutral state
assert has_element?(view, "[data-testid='city'] .opacity-40")
@@ -310,7 +335,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
test "multiple fields can be tested for icon states", %{conn: conn} do
conn = conn_with_oidc_user(conn)
- {:ok, view, html} = live(conn, "/members?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)
assert html =~ "hero-chevron-down"
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 d86893d9..427cbead 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
@@ -89,17 +89,19 @@ defmodule MvWeb.MemberLive.Index.FieldVisibilityTest do
assert result["email"] == true
end
- test "defaults to true when field not in settings" do
+ test "defaults to the curated set when field not in settings" do
user_selection = %{}
- settings = %{member_field_visibility: %{first_name: false}}
+ settings = %{member_field_visibility: %{join_date: false}}
custom_fields = []
result = FieldVisibility.merge_with_global_settings(user_selection, settings, custom_fields)
- # first_name from settings
- assert result["first_name"] == false
- # email defaults to true (not in settings)
- assert result["email"] == true
+ # join_date from settings overrides its curated default
+ assert result["join_date"] == false
+ # name is in the curated default set -> visible
+ assert result["name"] == true
+ # email is not in the curated default set -> hidden
+ assert result["email"] == false
end
test "handles custom fields visibility" do
@@ -160,9 +162,9 @@ defmodule MvWeb.MemberLive.Index.FieldVisibilityTest do
result = FieldVisibility.merge_with_global_settings(user_selection, settings, custom_fields)
- # Should default all fields to true
- assert result["first_name"] == true
- assert result["email"] == true
+ # Falls back to the curated default set (no crash on nil)
+ assert result["join_date"] == true
+ assert result["email"] == false
end
test "handles missing member_field_visibility key" do
@@ -172,9 +174,9 @@ defmodule MvWeb.MemberLive.Index.FieldVisibilityTest do
result = FieldVisibility.merge_with_global_settings(user_selection, settings, custom_fields)
- # Should default all fields to true
- assert result["first_name"] == true
- assert result["email"] == true
+ # Falls back to the curated default set
+ assert result["join_date"] == true
+ assert result["email"] == false
end
test "includes all fields in result" do
diff --git a/test/mv_web/member_live/index_address_cell_test.exs b/test/mv_web/member_live/index_address_cell_test.exs
new file mode 100644
index 00000000..53d8dc04
--- /dev/null
+++ b/test/mv_web/member_live/index_address_cell_test.exs
@@ -0,0 +1,87 @@
+defmodule MvWeb.MemberLive.IndexAddressCellTest do
+ @moduledoc """
+ §1.1 — The address renders as one composite cell: line 1 = street + house
+ number, line 2 = postal code + city.
+ §1.12 — City and postal code stay findable via search despite the composite
+ cell.
+ """
+ use MvWeb.ConnCase, async: false
+
+ import Phoenix.LiveViewTest
+
+ alias Mv.Helpers.SystemActor
+
+ defp create_member(attrs) do
+ {:ok, member} =
+ Mv.Membership.create_member(
+ Map.merge(
+ %{first_name: "Addr", last_name: "Tester", email: "addr@example.com"},
+ attrs
+ ),
+ actor: SystemActor.get_system_actor()
+ )
+
+ member
+ end
+
+ test "address shows as one cell with street/house on line 1 and postal/city on line 2", %{
+ conn: conn
+ } do
+ member =
+ create_member(%{
+ street: "Hauptstraße",
+ house_number: "12a",
+ postal_code: "10115",
+ city: "Berlin"
+ })
+
+ conn = conn_with_oidc_user(conn)
+ {:ok, view, _html} = live(conn, ~p"/members")
+
+ cell =
+ view
+ |> element("#row-#{member.id} [data-testid='member-address']")
+ |> render()
+
+ assert cell =~ "Hauptstraße"
+ assert cell =~ "12a"
+ assert cell =~ "10115"
+ assert cell =~ "Berlin"
+
+ # Composite, not four separate sortable address columns.
+ refute has_element?(view, "[data-testid='street']")
+ refute has_element?(view, "[data-testid='city']")
+ end
+
+ test "member with no address parts renders a screen-reader label, not a blank cell", %{
+ conn: conn
+ } do
+ member = create_member(%{email: "no-address@example.com"})
+
+ conn = conn_with_oidc_user(conn)
+ {:ok, view, _html} = live(conn, ~p"/members")
+
+ row = view |> element("#row-#{member.id}") |> render()
+
+ assert row =~ "No address"
+ refute has_element?(view, "#row-#{member.id} [data-testid='member-address']")
+ end
+
+ test "members stay findable by city via search", %{conn: conn} do
+ member = create_member(%{city: "Hamburg", email: "city-search@example.com"})
+
+ conn = conn_with_oidc_user(conn)
+ {:ok, view, _html} = live(conn, ~p"/members?query=Hamburg")
+
+ assert has_element?(view, "#row-#{member.id}")
+ end
+
+ test "members stay findable by postal code via search", %{conn: conn} do
+ member = create_member(%{postal_code: "99999", email: "postal-search@example.com"})
+
+ conn = conn_with_oidc_user(conn)
+ {:ok, view, _html} = live(conn, ~p"/members?query=99999")
+
+ assert has_element?(view, "#row-#{member.id}")
+ end
+end
diff --git a/test/mv_web/member_live/index_custom_fields_sorting_test.exs b/test/mv_web/member_live/index_custom_fields_sorting_test.exs
index 4119205c..0355766b 100644
--- a/test/mv_web/member_live/index_custom_fields_sorting_test.exs
+++ b/test/mv_web/member_live/index_custom_fields_sorting_test.exs
@@ -206,14 +206,20 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsSortingTest do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} =
- live(conn, "/members?query=&sort_field=custom_field_#{field.id}&sort_order=desc")
+ live(
+ conn,
+ "/members?fields=email&query=&sort_field=custom_field_#{field.id}&sort_order=desc"
+ )
# Click on email column
view
|> element("[data-testid='email']")
|> render_click()
- assert_patch(view, "/members?query=&sort_field=email&sort_order=asc")
+ # The fields param rides along on the patch, so assert the sort outcome.
+ path = assert_patch(view)
+ assert path =~ "sort_field=email"
+ assert path =~ "sort_order=asc"
end
test "clicking custom field column after regular column works", %{
diff --git a/test/mv_web/member_live/index_default_columns_test.exs b/test/mv_web/member_live/index_default_columns_test.exs
new file mode 100644
index 00000000..04d3c993
--- /dev/null
+++ b/test/mv_web/member_live/index_default_columns_test.exs
@@ -0,0 +1,58 @@
+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.
+ """
+ use MvWeb.ConnCase, async: false
+
+ import Phoenix.LiveViewTest
+
+ alias Mv.Helpers.SystemActor
+
+ setup %{conn: conn} do
+ {:ok, _} =
+ Mv.Membership.create_member(
+ %{
+ first_name: "Col",
+ last_name: "Default",
+ email: "col@example.com",
+ street: "Musterweg",
+ house_number: "1",
+ postal_code: "10115",
+ city: "Berlin"
+ },
+ actor: SystemActor.get_system_actor()
+ )
+
+ %{conn: conn_with_oidc_user(conn)}
+ end
+
+ test "curated columns are visible by default", %{conn: conn} do
+ {:ok, view, _html} = live(conn, ~p"/members")
+
+ # Composite Name + Address cells.
+ assert has_element?(view, "[data-testid='member-name']")
+ assert has_element?(view, "[data-testid='member-address']")
+ # 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']")
+ assert has_element?(view, "th", "Membership Fee Status")
+ assert has_element?(view, "[data-testid='groups']")
+ end
+
+ 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
+ refute has_element?(view, "[data-testid='#{field}']")
+ end
+ end
+
+ test "an explicit field selection still overrides the curated default", %{conn: conn} do
+ {:ok, view, _html} = live(conn, ~p"/members?fields=email")
+
+ assert has_element?(view, "[data-testid='email']")
+ refute has_element?(view, "[data-testid='member-name']")
+ 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 63dff1c4..71744f83 100644
--- a/test/mv_web/member_live/index_field_visibility_test.exs
+++ b/test/mv_web/member_live/index_field_visibility_test.exs
@@ -148,20 +148,20 @@ defmodule MvWeb.MemberLive.IndexFieldVisibilityTest do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
- # Verify email is visible initially
+ # The curated Name column carries the email, so it is visible initially.
html = render(view)
assert html =~ "alice@example.com"
- # Open dropdown and hide email
+ # Open dropdown and hide the Name column
view
|> element("button[aria-controls='field-visibility-menu']")
|> render_click()
view
- |> element("button[phx-click='select_item'][phx-value-item='email']")
+ |> element("button[phx-click='select_item'][phx-value-item='name']")
|> render_click()
- # Email should no longer be visible
+ # The Name column (and the email it carries) is no longer visible
html = render(view)
refute html =~ "alice@example.com"
refute html =~ "bob@example.com"
@@ -309,13 +309,13 @@ defmodule MvWeb.MemberLive.IndexFieldVisibilityTest do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
- # Hide a field via dropdown
+ # Hide the curated Name column (which carries the email) via dropdown
view
|> element("button[aria-controls='field-visibility-menu']")
|> render_click()
view
- |> element("button[phx-click='select_item'][phx-value-item='email']")
+ |> element("button[phx-click='select_item'][phx-value-item='name']")
|> render_click()
html = render(view)
@@ -429,7 +429,7 @@ defmodule MvWeb.MemberLive.IndexFieldVisibilityTest do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
- # Verify email is visible initially
+ # The curated Name column carries the email, so it is visible initially.
html = render(view)
assert html =~ "alice@example.com"
@@ -438,12 +438,12 @@ defmodule MvWeb.MemberLive.IndexFieldVisibilityTest do
|> element("button[aria-controls='field-visibility-menu']")
|> render_click()
- # Simulate Enter key press on email field button
+ # Simulate Enter key press on the Name field button
view
- |> element("button[phx-click='select_item'][phx-value-item='email']")
+ |> element("button[phx-click='select_item'][phx-value-item='name']")
|> render_keydown(%{key: "Enter"})
- # Email should no longer be visible
+ # The Name column (and the email it carries) is no longer visible
html = render(view)
refute html =~ "alice@example.com"
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 8ce82aaa..8b92cb90 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
@@ -125,7 +125,10 @@ defmodule MvWeb.MemberLive.IndexGroupsUrlParamsTest do
conn = conn_with_oidc_user(conn)
{:ok, view, html} =
- live(conn, "/members?sort_field=first_name&sort_order=desc&group_#{group1.id}=in")
+ live(
+ conn,
+ "/members?fields=first_name&sort_field=first_name&sort_order=desc&group_#{group1.id}=in"
+ )
assert html =~ member1.first_name
assert has_element?(view, "[data-testid='first_name'][aria-label*='descending']")
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 f3dadac5..6c222276 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
@@ -43,22 +43,23 @@ defmodule MvWeb.MemberLive.IndexMemberFieldsDisplayTest do
end
end
- test "respects show_in_overview config", %{conn: conn, member1: m} do
+ test "respects show_in_overview config", %{conn: conn} do
+ # Global settings still drive column visibility: hiding a curated default
+ # column via settings removes it, while unaffected columns stay visible.
{:ok, settings} = Mv.Membership.get_settings()
- fields_to_hide = [:street, :house_number]
{:ok, _} =
Mv.Membership.update_settings(settings, %{
- member_field_visibility: Map.new(fields_to_hide, &{Atom.to_string(&1), false})
+ member_field_visibility: %{"join_date" => false}
})
conn = conn_with_oidc_user(conn)
# Use search query to filter to only the expected member (Alice)
# This significantly improves test performance by avoiding loading all members from other tests
- {:ok, _view, html} = live(conn, "/members?query=Alice")
+ {:ok, view, _html} = live(conn, "/members?query=Alice")
- assert html =~ "Email"
- assert html =~ m.email
- refute html =~ m.street
+ refute has_element?(view, "[data-testid='join_date']")
+ # The composite Name column (and its email content) is unaffected.
+ assert has_element?(view, "[data-testid='member-name']")
end
end
diff --git a/test/mv_web/member_live/index_search_clear_test.exs b/test/mv_web/member_live/index_search_clear_test.exs
new file mode 100644
index 00000000..14a8aa25
--- /dev/null
+++ b/test/mv_web/member_live/index_search_clear_test.exs
@@ -0,0 +1,56 @@
+defmodule MvWeb.MemberLive.IndexSearchClearTest do
+ @moduledoc """
+ §1.3 — Activating the search clear (×) control clears the query, removes the
+ URL search param, and resets the result set to the unfiltered list.
+ """
+ use MvWeb.ConnCase, async: false
+
+ import Phoenix.LiveViewTest
+
+ alias Mv.Helpers.SystemActor
+
+ setup %{conn: conn} do
+ actor = SystemActor.get_system_actor()
+
+ {:ok, _} =
+ Mv.Membership.create_member(
+ %{first_name: "Findable", last_name: "One", email: "findable@example.com"},
+ actor: actor
+ )
+
+ {:ok, other} =
+ Mv.Membership.create_member(
+ %{first_name: "Other", last_name: "Two", email: "other@example.com"},
+ actor: actor
+ )
+
+ %{conn: conn_with_oidc_user(conn), other: other}
+ end
+
+ test "clear control only appears when a query is present", %{conn: conn} do
+ {:ok, no_query, _} = live(conn, ~p"/members")
+ refute has_element?(no_query, "[data-testid='search-clear']")
+
+ {:ok, with_query, _} = live(conn, ~p"/members?query=Findable")
+ assert has_element?(with_query, "[data-testid='search-clear']")
+ end
+
+ test "clear resets query, URL param and the result set", %{conn: conn, other: other} do
+ {:ok, view, _html} = live(conn, ~p"/members?query=Findable")
+
+ # The filtered list excludes the non-matching member.
+ refute has_element?(view, "#row-#{other.id}")
+
+ view |> element("[data-testid='search-clear']") |> render_click()
+
+ # Query cleared in the URL (search param reset to empty).
+ path = assert_patch(view)
+ assert path =~ "query="
+ refute path =~ "query=Findable"
+
+ # Result set resets to the unfiltered list (the previously excluded member returns).
+ assert has_element?(view, "#row-#{other.id}")
+ # The input no longer carries the query.
+ refute has_element?(view, "[data-testid='search-input'][value='Findable']")
+ end
+end
diff --git a/test/mv_web/member_live/index_sticky_pinned_test.exs b/test/mv_web/member_live/index_sticky_pinned_test.exs
new file mode 100644
index 00000000..95eac861
--- /dev/null
+++ b/test/mv_web/member_live/index_sticky_pinned_test.exs
@@ -0,0 +1,56 @@
+defmodule MvWeb.MemberLive.IndexStickyPinnedTest do
+ @moduledoc """
+ §1.19 — When the table scrolls vertically and horizontally, the header stays
+ sticky and the identifier (Name) column stays pinned; overflowing cell content
+ truncates with ellipsis and exposes a hover/focus tooltip.
+ """
+ use MvWeb.ConnCase, async: false
+
+ import Phoenix.LiveViewTest
+
+ alias Mv.Helpers.SystemActor
+
+ @moduletag :ui
+
+ setup %{conn: conn} do
+ {:ok, _} =
+ Mv.Membership.create_member(
+ %{
+ first_name: "Reginald",
+ last_name: "Worthington-Smythe",
+ email: "reginald@example.com",
+ street: "A Very Long Street Name That Overflows",
+ house_number: "123",
+ postal_code: "10115",
+ city: "Berlin"
+ },
+ actor: SystemActor.get_system_actor()
+ )
+
+ %{conn: conn_with_oidc_user(conn)}
+ end
+
+ test "header is sticky and the identifier column is pinned", %{conn: conn} do
+ {:ok, _view, html} = live(conn, ~p"/members")
+
+ # Sticky header (desktop).
+ assert html =~ "lg:sticky"
+ assert html =~ "lg:top-0"
+
+ # Checkbox column pinned at left-0, identifier (Name) column pinned beside it.
+ assert html =~ "left-0"
+ assert html =~ "left-12"
+ end
+
+ test "composite cells truncate and expose a tooltip via title", %{conn: conn} do
+ {:ok, view, _html} = live(conn, ~p"/members")
+
+ name_cell = view |> element("[data-testid='member-name']") |> render()
+ assert name_cell =~ "truncate"
+ assert name_cell =~ ~s(title="Reginald Worthington-Smythe")
+
+ address_cell = view |> element("[data-testid='member-address']") |> render()
+ assert address_cell =~ "truncate"
+ assert address_cell =~ ~s(title="A Very Long Street Name That Overflows 123")
+ end
+end
diff --git a/test/mv_web/member_live/index_test.exs b/test/mv_web/member_live/index_test.exs
index 8f31beb1..e1de77a8 100644
--- a/test/mv_web/member_live/index_test.exs
+++ b/test/mv_web/member_live/index_test.exs
@@ -154,9 +154,13 @@ 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.
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")
+ {:ok, view, _html} = live(conn, "/members?fields=email")
# The component data test ids are built with the name of the field
# First click – should sort ASC
@@ -164,32 +168,44 @@ defmodule MvWeb.MemberLive.IndexTest do
|> element("[data-testid='email']")
|> render_click()
- # The LiveView pushes a patch with the new query params
- assert_patch(view, "/members?query=&sort_field=email&sort_order=asc")
+ path_asc = assert_patch(view)
+ assert path_asc =~ "sort_field=email"
+ assert path_asc =~ "sort_order=asc"
# Second click – toggles to DESC
view
|> element("[data-testid='email']")
|> render_click()
- assert_patch(view, "/members?query=&sort_field=email&sort_order=desc")
+ path_desc = assert_patch(view)
+ assert path_desc =~ "sort_field=email"
+ assert path_desc =~ "sort_order=desc"
end
test "clicking different column header resets order to ascending", %{conn: conn} do
conn = conn_with_oidc_user(conn)
- {:ok, view, _html} = live(conn, "/members?sort_field=email&sort_order=desc")
+
+ {:ok, view, _html} =
+ live(conn, "/members?fields=first_name,email&sort_field=email&sort_order=desc")
# Click on a different column
view
|> element("[data-testid='first_name']")
|> render_click()
- assert_patch(view, "/members?query=&sort_field=first_name&sort_order=asc")
+ path = assert_patch(view)
+ assert path =~ "sort_field=first_name"
+ assert path =~ "sort_order=asc"
end
test "all sortable columns work correctly", %{conn: conn} do
conn = conn_with_oidc_user(conn)
- {:ok, view, _html} = live(conn, "/members")
+
+ {:ok, view, _html} =
+ live(
+ conn,
+ "/members?fields=first_name,email,street,house_number,postal_code,city,country,join_date"
+ )
# default ascending sorting with first name
assert has_element?(view, "[data-testid='first_name'][aria-label='ascending']")
@@ -209,30 +225,40 @@ defmodule MvWeb.MemberLive.IndexTest do
|> element("[data-testid='#{field}']")
|> render_click()
- assert_patch(view, "/members?query=&sort_field=#{field}&sort_order=asc")
+ path = assert_patch(view)
+ assert path =~ "sort_field=#{field}"
+ assert path =~ "sort_order=asc"
end
end
test "sorting works with search query", %{conn: conn} do
conn = conn_with_oidc_user(conn)
- {:ok, view, _html} = live(conn, "/members?query=test")
+ {:ok, view, _html} = live(conn, "/members?fields=email&query=test")
view
|> element("[data-testid='email']")
|> render_click()
- assert_patch(view, "/members?query=test&sort_field=email&sort_order=asc")
+ path = assert_patch(view)
+ assert path =~ "query=test"
+ assert path =~ "sort_field=email"
+ assert path =~ "sort_order=asc"
end
test "sorting maintains search query when toggling order", %{conn: conn} do
conn = conn_with_oidc_user(conn)
- {:ok, view, _html} = live(conn, "/members?query=test&sort_field=email&sort_order=asc")
+
+ {:ok, view, _html} =
+ live(conn, "/members?fields=email&query=test&sort_field=email&sort_order=asc")
view
|> element("[data-testid='email']")
|> render_click()
- assert_patch(view, "/members?query=test&sort_field=email&sort_order=desc")
+ path = assert_patch(view)
+ assert path =~ "query=test"
+ assert path =~ "sort_field=email"
+ assert path =~ "sort_order=desc"
end
end
@@ -240,7 +266,9 @@ defmodule MvWeb.MemberLive.IndexTest do
@describetag :ui
test "handle_params reads sort query and applies it", %{conn: conn} do
conn = conn_with_oidc_user(conn)
- {:ok, view, _html} = live(conn, "/members?query=&sort_field=email&sort_order=desc")
+
+ {:ok, view, _html} =
+ live(conn, "/members?fields=email&query=&sort_field=email&sort_order=desc")
# Check that the sort state is correctly applied
assert has_element?(view, "[data-testid='email'][aria-label='descending']")
@@ -248,7 +276,9 @@ defmodule MvWeb.MemberLive.IndexTest do
test "handle_params handles invalid sort field gracefully", %{conn: conn} do
conn = conn_with_oidc_user(conn)
- {:ok, view, _html} = live(conn, "/members?query=&sort_field=invalid_field&sort_order=asc")
+
+ {:ok, view, _html} =
+ live(conn, "/members?fields=first_name&query=&sort_field=invalid_field&sort_order=asc")
# Should not crash and should show default first name order
assert has_element?(view, "[data-testid='first_name'][aria-label='ascending']")
@@ -256,7 +286,9 @@ defmodule MvWeb.MemberLive.IndexTest do
test "handle_params preserves search query with sort params", %{conn: conn} do
conn = conn_with_oidc_user(conn)
- {:ok, view, _html} = live(conn, "/members?query=test&sort_field=email&sort_order=desc")
+
+ {:ok, view, _html} =
+ live(conn, "/members?fields=email&query=test&sort_field=email&sort_order=desc")
# Both search and sort should be preserved
assert has_element?(view, "[data-testid='email'][aria-label='descending']")
@@ -267,7 +299,9 @@ defmodule MvWeb.MemberLive.IndexTest do
@describetag :ui
test "search maintains sort state", %{conn: conn} do
conn = conn_with_oidc_user(conn)
- {:ok, view, _html} = live(conn, "/members?query=&sort_field=email&sort_order=desc")
+
+ {:ok, view, _html} =
+ live(conn, "/members?fields=email&query=&sort_field=email&sort_order=desc")
# Perform search
view
@@ -280,7 +314,9 @@ defmodule MvWeb.MemberLive.IndexTest do
test "sort maintains search state", %{conn: conn} do
conn = conn_with_oidc_user(conn)
- {:ok, view, _html} = live(conn, "/members?query=test&sort_field=email&sort_order=asc")
+
+ {:ok, view, _html} =
+ live(conn, "/members?fields=email&query=test&sort_field=email&sort_order=asc")
# Perform sort
view
@@ -288,7 +324,10 @@ defmodule MvWeb.MemberLive.IndexTest do
|> render_click()
# Search state should be maintained
- assert_patch(view, "/members?query=test&sort_field=email&sort_order=desc")
+ path = assert_patch(view)
+ assert path =~ "query=test"
+ assert path =~ "sort_field=email"
+ assert path =~ "sort_order=desc"
end
end
@@ -1578,7 +1617,7 @@ defmodule MvWeb.MemberLive.IndexTest do
boolean_field = create_boolean_custom_field()
{:ok, view, _html} =
- live(conn, "/members?bf_#{boolean_field.id}=true")
+ live(conn, "/members?fields=email&bf_#{boolean_field.id}=true")
# Test sort toggle preserves filter
view