From af2cc2e0d492447d3d9d81901cb1575cf552faf1 Mon Sep 17 00:00:00 2001 From: Simon Date: Fri, 3 Jul 2026 11:35:46 +0200 Subject: [PATCH] feat(member): condense the overview into composite name and address cells --- assets/css/app.css | 184 ++++++++++++++++++ lib/mv_web/components/core_components.ex | 16 +- .../live/components/search_bar_component.ex | 17 ++ lib/mv_web/live/member_live/index.html.heex | 78 ++++++-- .../member_live/index/field_visibility.ex | 27 ++- lib/mv_web/translations/member_fields.ex | 2 + .../components/sort_header_component_test.exs | 75 ++++--- .../index/field_visibility_test.exs | 26 +-- .../member_live/index_address_cell_test.exs | 87 +++++++++ .../index_custom_fields_sorting_test.exs | 10 +- .../index_default_columns_test.exs | 58 ++++++ .../index_field_visibility_test.exs | 20 +- .../index_groups_url_params_test.exs | 5 +- .../index_member_fields_display_test.exs | 15 +- .../member_live/index_search_clear_test.exs | 56 ++++++ .../member_live/index_sticky_pinned_test.exs | 56 ++++++ test/mv_web/member_live/index_test.exs | 77 ++++++-- 17 files changed, 716 insertions(+), 93 deletions(-) create mode 100644 test/mv_web/member_live/index_address_cell_test.exs create mode 100644 test/mv_web/member_live/index_default_columns_test.exs create mode 100644 test/mv_web/member_live/index_search_clear_test.exs create mode 100644 test/mv_web/member_live/index_sticky_pinned_test.exs 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" /> + """ @@ -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")}> +
+
{line1}
+
{line2}
+
+ + <: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