Rework member overview #553
21 changed files with 1643 additions and 263 deletions
|
|
@ -414,15 +414,46 @@ Hooks.LoadMorePrefetch = {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reads a browser cookie value by name (used to echo the persisted member
|
||||||
|
// view-settings to the server on connect, since the live socket's connect-info
|
||||||
|
// does not expose cookies).
|
||||||
|
function getCookie(name) {
|
||||||
|
const match = document.cookie.match(new RegExp("(?:^|; )" + name + "=([^;]*)"))
|
||||||
|
return match ? decodeURIComponent(match[1]) : null
|
||||||
|
}
|
||||||
|
|
||||||
let liveSocket = new LiveSocket("/live", Socket, {
|
let liveSocket = new LiveSocket("/live", Socket, {
|
||||||
longPollFallbackMs: 2500,
|
longPollFallbackMs: 2500,
|
||||||
params: {
|
params: {
|
||||||
_csrf_token: csrfToken,
|
_csrf_token: csrfToken,
|
||||||
timezone: getBrowserTimezone()
|
timezone: getBrowserTimezone(),
|
||||||
|
view_settings: getCookie("member_view_settings")
|
||||||
},
|
},
|
||||||
hooks: Hooks
|
hooks: Hooks
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Persist the member-overview view settings per browser/device (§1.5/§1.20). The
|
||||||
|
// LiveView pushes "store-view-settings" (density + compact field toggles) on
|
||||||
|
// change; we write a long-lived cookie that the LiveView reads back on the next
|
||||||
|
// full page load (via the request cookie on the dead render and via connect
|
||||||
|
// params on the connected mount).
|
||||||
|
// Return the members list to the top after a sort or filter change. The server
|
||||||
|
// resets the keyset stream to page 1 on those changes and pushes this event; a
|
||||||
|
// user scrolled down would otherwise be stranded past the shorter content with
|
||||||
|
// infinite scroll not re-arming. No-op if the scroll container is absent.
|
||||||
|
window.addEventListener("phx:members:scroll-top", () => {
|
||||||
|
const el = document.getElementById("members-table-guard")
|
||||||
|
if (el) el.scrollTop = 0
|
||||||
|
})
|
||||||
|
|
||||||
|
window.addEventListener("phx:store-view-settings", (e) => {
|
||||||
|
const json = e.detail && e.detail.view_settings
|
||||||
|
if (typeof json === "string" && json.length > 0) {
|
||||||
|
const maxAge = 365 * 24 * 60 * 60
|
||||||
|
document.cookie = `member_view_settings=${encodeURIComponent(json)};path=/;max-age=${maxAge};samesite=lax`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
// Listen for custom events from LiveView
|
// Listen for custom events from LiveView
|
||||||
window.addEventListener("phx:set-input-value", (e) => {
|
window.addEventListener("phx:set-input-value", (e) => {
|
||||||
const {id, value} = e.detail
|
const {id, value} = e.detail
|
||||||
|
|
@ -432,15 +463,6 @@ window.addEventListener("phx:set-input-value", (e) => {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// Return the members list to the top after a sort or filter change. The server
|
|
||||||
// resets the keyset stream to page 1 on those changes and pushes this event; a
|
|
||||||
// user scrolled down would otherwise be stranded past the shorter content with
|
|
||||||
// infinite scroll not re-arming. No-op if the scroll container is absent.
|
|
||||||
window.addEventListener("phx:members:scroll-top", () => {
|
|
||||||
const el = document.getElementById("members-table-guard")
|
|
||||||
if (el) el.scrollTop = 0
|
|
||||||
})
|
|
||||||
|
|
||||||
// Show progress bar on live navigation and form submits
|
// Show progress bar on live navigation and form submits
|
||||||
topbar.config({barColors: {0: "#29d"}, shadowColor: "rgba(0, 0, 0, .3)"})
|
topbar.config({barColors: {0: "#29d"}, shadowColor: "rgba(0, 0, 0, .3)"})
|
||||||
window.addEventListener("phx:page-loading-start", _info => topbar.show(300))
|
window.addEventListener("phx:page-loading-start", _info => topbar.show(300))
|
||||||
|
|
|
||||||
|
|
@ -250,7 +250,7 @@ defmodule MvWeb.CoreComponents do
|
||||||
|
|
||||||
attr :size, :any,
|
attr :size, :any,
|
||||||
default: "md",
|
default: "md",
|
||||||
doc: "Badge size: sm | md"
|
doc: "Badge size: xs | sm | md"
|
||||||
|
|
||||||
attr :sr_label, :string,
|
attr :sr_label, :string,
|
||||||
default: nil,
|
default: nil,
|
||||||
|
|
@ -269,7 +269,7 @@ defmodule MvWeb.CoreComponents do
|
||||||
|
|
||||||
variant_class = "badge-#{variant}"
|
variant_class = "badge-#{variant}"
|
||||||
style_class = badge_style_class(style)
|
style_class = badge_style_class(style)
|
||||||
size_class = "badge-#{size}"
|
size_class = badge_size_class(size)
|
||||||
# Outline has transparent bg in DaisyUI; add bg so it stays visible on base-200/base-300
|
# Outline has transparent bg in DaisyUI; add bg so it stays visible on base-200/base-300
|
||||||
outline_bg = if style == "outline", do: "bg-base-100", else: nil
|
outline_bg = if style == "outline", do: "bg-base-100", else: nil
|
||||||
|
|
||||||
|
|
@ -309,6 +309,13 @@ defmodule MvWeb.CoreComponents do
|
||||||
defp badge_style_class("outline"), do: "badge-outline"
|
defp badge_style_class("outline"), do: "badge-outline"
|
||||||
defp badge_style_class(_), do: nil
|
defp badge_style_class(_), do: nil
|
||||||
|
|
||||||
|
# Literal strings so Tailwind's content scanner sees badge-xs / badge-sm / badge-md / badge-lg
|
||||||
|
# and does not purge them from the CSS bundle (string interpolation "badge-#{size}" would be invisible).
|
||||||
|
defp badge_size_class("xs"), do: "badge-xs"
|
||||||
|
defp badge_size_class("sm"), do: "badge-sm"
|
||||||
|
defp badge_size_class("lg"), do: "badge-lg"
|
||||||
|
defp badge_size_class(_), do: "badge-md"
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Renders a visually empty table cell with screen-reader-only text (WCAG).
|
Renders a visually empty table cell with screen-reader-only text (WCAG).
|
||||||
|
|
||||||
|
|
@ -443,6 +450,11 @@ defmodule MvWeb.CoreComponents do
|
||||||
attr :selected, :map, default: %{}
|
attr :selected, :map, default: %{}
|
||||||
attr :open, :boolean, default: false, doc: "Whether the dropdown is open"
|
attr :open, :boolean, default: false, doc: "Whether the dropdown is open"
|
||||||
attr :show_select_buttons, :boolean, default: false, doc: "Show select all/none buttons"
|
attr :show_select_buttons, :boolean, default: false, doc: "Show select all/none buttons"
|
||||||
|
|
||||||
|
attr :show_reset_button, :boolean,
|
||||||
|
default: false,
|
||||||
|
doc: "Show a reset-to-default icon button next to All/None (emits reset_fields)"
|
||||||
|
|
||||||
attr :phx_target, :any, required: true, doc: "The LiveView/LiveComponent target for events"
|
attr :phx_target, :any, required: true, doc: "The LiveView/LiveComponent target for events"
|
||||||
attr :menu_class, :string, default: nil, doc: "Additional CSS classes for the menu"
|
attr :menu_class, :string, default: nil, doc: "Additional CSS classes for the menu"
|
||||||
attr :menu_width, :string, default: "w-64", doc: "Width class for the menu (default: w-64)"
|
attr :menu_width, :string, default: "w-64", doc: "Width class for the menu (default: w-64)"
|
||||||
|
|
@ -546,6 +558,19 @@ defmodule MvWeb.CoreComponents do
|
||||||
>
|
>
|
||||||
{gettext("None")}
|
{gettext("None")}
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
:if={@show_reset_button}
|
||||||
|
type="button"
|
||||||
|
role="menuitem"
|
||||||
|
aria-label={gettext("Reset to default")}
|
||||||
|
title={gettext("Reset to default")}
|
||||||
|
phx-click="reset_fields"
|
||||||
|
phx-target={@phx_target}
|
||||||
|
class="btn btn-xs btn-ghost"
|
||||||
|
data-testid="dropdown-reset"
|
||||||
|
>
|
||||||
|
<.icon name="hero-arrow-uturn-left" class="size-4" />
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
|
|
@ -938,6 +963,11 @@ defmodule MvWeb.CoreComponents do
|
||||||
attr :sort_field, :any, default: nil, doc: "current sort field"
|
attr :sort_field, :any, default: nil, doc: "current sort field"
|
||||||
attr :sort_order, :atom, default: nil, doc: "current sort order"
|
attr :sort_order, :atom, default: nil, doc: "current sort order"
|
||||||
|
|
||||||
|
attr :size_class, :string,
|
||||||
|
default: "",
|
||||||
|
doc:
|
||||||
|
"optional DaisyUI table-size class controlling row density (e.g. table-xs, table-md); driven by the view-settings density value"
|
||||||
|
|
||||||
attr :sticky_header, :boolean,
|
attr :sticky_header, :boolean,
|
||||||
default: false,
|
default: false,
|
||||||
doc:
|
doc:
|
||||||
|
|
@ -1016,7 +1046,7 @@ defmodule MvWeb.CoreComponents do
|
||||||
data-sticky-first-col-rows={@sticky_first_col && "true"}
|
data-sticky-first-col-rows={@sticky_first_col && "true"}
|
||||||
phx-hook={@row_click && "TableRowKeydown"}
|
phx-hook={@row_click && "TableRowKeydown"}
|
||||||
>
|
>
|
||||||
<table class="table table-zebra">
|
<table class={["table table-zebra", @size_class]}>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th
|
<th
|
||||||
|
|
@ -1223,7 +1253,7 @@ defmodule MvWeb.CoreComponents do
|
||||||
defp table_th_sticky_class(_), do: nil
|
defp table_th_sticky_class(_), do: nil
|
||||||
|
|
||||||
defp sticky_th_classes,
|
defp sticky_th_classes,
|
||||||
do: "lg:sticky lg:top-0 bg-base-100 z-10 hover:z-20 focus-within:z-20"
|
do: "lg:sticky lg:top-0 bg-base-100 hover:bg-base-100 z-10 hover:z-20 focus-within:z-20"
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Renders a reorderable table (sortable list) with drag handle and keyboard support.
|
Renders a reorderable table (sortable list) with drag handle and keyboard support.
|
||||||
|
|
|
||||||
|
|
@ -69,12 +69,13 @@ defmodule MvWeb.Components.FieldVisibilityDropdownComponent do
|
||||||
<.dropdown_menu
|
<.dropdown_menu
|
||||||
id="field-visibility-menu"
|
id="field-visibility-menu"
|
||||||
icon="hero-adjustments-horizontal"
|
icon="hero-adjustments-horizontal"
|
||||||
button_label={gettext("Show/Hide Columns")}
|
button_label={gettext("Columns")}
|
||||||
items={@all_items}
|
items={@all_items}
|
||||||
checkboxes={true}
|
checkboxes={true}
|
||||||
selected={@selected_fields}
|
selected={@selected_fields}
|
||||||
open={@open}
|
open={@open}
|
||||||
show_select_buttons={true}
|
show_select_buttons={true}
|
||||||
|
show_reset_button={true}
|
||||||
phx_target={@myself}
|
phx_target={@myself}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -115,6 +116,13 @@ defmodule MvWeb.Components.FieldVisibilityDropdownComponent do
|
||||||
{:noreply, assign(socket, :selected_fields, all)}
|
{:noreply, assign(socket, :selected_fields, all)}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# reset to the curated default column set (the parent owns the settings needed
|
||||||
|
# to compute the default, so it recomputes and re-applies the selection)
|
||||||
|
def handle_event("reset_fields", _params, socket) do
|
||||||
|
send(self(), {:fields_reset})
|
||||||
|
{:noreply, socket}
|
||||||
|
end
|
||||||
|
|
||||||
# select none
|
# select none
|
||||||
def handle_event("select_none", _params, socket) do
|
def handle_event("select_none", _params, socket) do
|
||||||
none =
|
none =
|
||||||
|
|
|
||||||
|
|
@ -32,11 +32,14 @@ defmodule MvWeb.Components.SortHeaderComponent do
|
||||||
>
|
>
|
||||||
{@label}
|
{@label}
|
||||||
<%= if @sort_field == @field do %>
|
<%= if @sort_field == @field do %>
|
||||||
<.icon name={if @sort_order == :asc, do: "hero-chevron-up", else: "hero-chevron-down"} />
|
<.icon
|
||||||
|
name={if @sort_order == :asc, do: "hero-chevron-up", else: "hero-chevron-down"}
|
||||||
|
class="sort-icon"
|
||||||
|
/>
|
||||||
<% else %>
|
<% else %>
|
||||||
<.icon
|
<.icon
|
||||||
name="hero-chevron-up-down"
|
name="hero-chevron-up-down"
|
||||||
class="opacity-40"
|
class="sort-icon opacity-40"
|
||||||
/>
|
/>
|
||||||
<% end %>
|
<% end %>
|
||||||
</.button>
|
</.button>
|
||||||
|
|
|
||||||
129
lib/mv_web/live/components/view_settings_dropdown_component.ex
Normal file
129
lib/mv_web/live/components/view_settings_dropdown_component.ex
Normal file
|
|
@ -0,0 +1,129 @@
|
||||||
|
defmodule MvWeb.Components.ViewSettingsDropdownComponent do
|
||||||
|
@moduledoc """
|
||||||
|
LiveComponent for the member-overview view settings (§1.20).
|
||||||
|
|
||||||
|
Renders an icon button with a tooltip that opens a dropdown of toggles:
|
||||||
|
|
||||||
|
* Compact mode — table row density (compact ↔ comfortable)
|
||||||
|
* Compact Member field — composite last+first name cell, with a sub-toggle
|
||||||
|
"include email"
|
||||||
|
* Compact address field — composite street / postal+city cell
|
||||||
|
|
||||||
|
Turning a composite field off surfaces the underlying separate columns in the
|
||||||
|
table. The component is display-only: it emits `{:view_setting_toggled, key}`
|
||||||
|
to the parent LiveView, which owns and persists the settings.
|
||||||
|
|
||||||
|
## Props
|
||||||
|
- `:density` — `:compact` or `:comfortable`
|
||||||
|
- `:compact_member` — boolean
|
||||||
|
- `:member_include_email` — boolean
|
||||||
|
- `:compact_address` — boolean
|
||||||
|
- `:id` — component id
|
||||||
|
"""
|
||||||
|
|
||||||
|
use MvWeb, :live_component
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def update(assigns, socket) do
|
||||||
|
{:ok,
|
||||||
|
socket
|
||||||
|
|> assign(assigns)
|
||||||
|
|> assign_new(:open, fn -> false end)}
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def render(assigns) do
|
||||||
|
~H"""
|
||||||
|
<div>
|
||||||
|
<.dropdown_menu
|
||||||
|
id="view-settings-menu"
|
||||||
|
icon="hero-cog-6-tooth"
|
||||||
|
button_label={gettext("View")}
|
||||||
|
open={@open}
|
||||||
|
phx_target={@myself}
|
||||||
|
menu_width="w-72"
|
||||||
|
testid="view-settings"
|
||||||
|
button_testid="view-settings-button"
|
||||||
|
>
|
||||||
|
<li role="none" class="mb-1 px-2">
|
||||||
|
<span class="font-semibold">{gettext("View settings")}</span>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<.toggle_item
|
||||||
|
setting="density"
|
||||||
|
label={gettext("Compact mode")}
|
||||||
|
checked={@density == :compact}
|
||||||
|
target={@myself}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<.toggle_item
|
||||||
|
setting="compact_member"
|
||||||
|
label={gettext("Compact Member field")}
|
||||||
|
checked={@compact_member}
|
||||||
|
target={@myself}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<%!-- The email line lives inside the composite Member cell, so this
|
||||||
|
sub-toggle is only meaningful while that cell is compact. When the
|
||||||
|
composite is off, email is its own column and this toggle is omitted. --%>
|
||||||
|
<li :if={@compact_member} role="none" class="pl-6">
|
||||||
|
<.toggle_item
|
||||||
|
setting="member_include_email"
|
||||||
|
label={gettext("include email")}
|
||||||
|
checked={@member_include_email}
|
||||||
|
target={@myself}
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<.toggle_item
|
||||||
|
setting="compact_address"
|
||||||
|
label={gettext("Compact address field")}
|
||||||
|
checked={@compact_address}
|
||||||
|
target={@myself}
|
||||||
|
/>
|
||||||
|
</.dropdown_menu>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
attr :setting, :string, required: true
|
||||||
|
attr :label, :string, required: true
|
||||||
|
attr :checked, :boolean, required: true
|
||||||
|
attr :target, :any, required: true
|
||||||
|
|
||||||
|
defp toggle_item(assigns) do
|
||||||
|
~H"""
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="menuitemcheckbox"
|
||||||
|
aria-checked={to_string(@checked)}
|
||||||
|
aria-label={@label}
|
||||||
|
tabindex="0"
|
||||||
|
class="flex items-center gap-2 px-2 py-1 rounded w-full text-left cursor-pointer hover:bg-base-200"
|
||||||
|
phx-click="toggle"
|
||||||
|
phx-value-setting={@setting}
|
||||||
|
phx-target={@target}
|
||||||
|
data-testid={"view-setting-#{String.replace(@setting, "_", "-")}"}
|
||||||
|
>
|
||||||
|
<span class={if @checked, do: "text-primary", else: "text-base-300"} aria-hidden="true">
|
||||||
|
<.icon name="hero-check" class="size-4 shrink-0" />
|
||||||
|
</span>
|
||||||
|
<span>{@label}</span>
|
||||||
|
</button>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_event("toggle_dropdown", _params, socket) do
|
||||||
|
{:noreply, assign(socket, :open, !socket.assigns.open)}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_event("close_dropdown", _params, socket) do
|
||||||
|
{:noreply, assign(socket, :open, false)}
|
||||||
|
end
|
||||||
|
|
||||||
|
def handle_event("toggle", %{"setting" => setting}, socket) do
|
||||||
|
send(self(), {:view_setting_toggled, String.to_existing_atom(setting)})
|
||||||
|
{:noreply, socket}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -41,6 +41,7 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
alias MvWeb.MemberLive.Index.Formatter
|
alias MvWeb.MemberLive.Index.Formatter
|
||||||
alias MvWeb.MemberLive.Index.MembershipFeeStatus
|
alias MvWeb.MemberLive.Index.MembershipFeeStatus
|
||||||
alias MvWeb.MemberLive.Index.OverviewQuery
|
alias MvWeb.MemberLive.Index.OverviewQuery
|
||||||
|
alias MvWeb.MemberLive.Index.ViewSettings
|
||||||
|
|
||||||
require Ash.Query
|
require Ash.Query
|
||||||
require Logger
|
require Logger
|
||||||
|
|
@ -123,16 +124,31 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
# Load user field selection from session
|
# Load user field selection from session
|
||||||
session_selection = FieldSelection.get_from_session(session)
|
session_selection = FieldSelection.get_from_session(session)
|
||||||
|
|
||||||
# FIX: ensure dropdown doesn’t show duplicate fields (e.g. membership fee status twice)
|
# Resolve the per-browser view settings first: the columns the manager offers
|
||||||
|
# (composite vs. constituent Name/Address columns) follow these settings (§7b).
|
||||||
|
view_settings = ViewSettings.resolve(session, connect_conn(socket), connect_params(socket))
|
||||||
|
compact_member = view_settings.compact_member
|
||||||
|
member_include_email = view_settings.member_include_email
|
||||||
|
compact_address = view_settings.compact_address
|
||||||
|
|
||||||
|
# The dropdown offers exactly the columns applicable to the current view
|
||||||
|
# settings, so composite and constituent columns are never both listed.
|
||||||
all_available_fields =
|
all_available_fields =
|
||||||
all_custom_fields
|
FieldVisibility.get_offered_fields(
|
||||||
|> FieldVisibility.get_all_available_fields()
|
all_custom_fields,
|
||||||
|
compact_member,
|
||||||
|
member_include_email,
|
||||||
|
compact_address
|
||||||
|
)
|
||||||
|
|
||||||
initial_selection =
|
initial_selection =
|
||||||
FieldVisibility.merge_with_global_settings(
|
FieldVisibility.merge_with_global_settings(
|
||||||
session_selection,
|
session_selection,
|
||||||
settings,
|
settings,
|
||||||
all_custom_fields
|
all_custom_fields,
|
||||||
|
compact_member: compact_member,
|
||||||
|
member_include_email: member_include_email,
|
||||||
|
compact_address: compact_address
|
||||||
)
|
)
|
||||||
|
|
||||||
socket =
|
socket =
|
||||||
|
|
@ -183,6 +199,7 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
|> assign(:mailto_bcc, "")
|
|> assign(:mailto_bcc, "")
|
||||||
|> assign(:recipient_count, 0)
|
|> assign(:recipient_count, 0)
|
||||||
|> assign(:mailto_disabled?, false)
|
|> assign(:mailto_disabled?, false)
|
||||||
|
|> assign_view_settings(view_settings)
|
||||||
|> stream_configure(:members, dom_id: &"row-#{&1.id}")
|
|> stream_configure(:members, dom_id: &"row-#{&1.id}")
|
||||||
|> stream(:members, [])
|
|> stream(:members, [])
|
||||||
|> assign_export_payload()
|
|> assign_export_payload()
|
||||||
|
|
@ -385,6 +402,11 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
- `{:fields_selected, selection}` - Select all/deselect all event from FieldVisibilityDropdownComponent
|
- `{:fields_selected, selection}` - Select all/deselect all event from FieldVisibilityDropdownComponent
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_info({:view_setting_toggled, key}, socket) do
|
||||||
|
{:noreply, toggle_view_setting(socket, key)}
|
||||||
|
end
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def handle_info({:search_changed, q}, socket) do
|
def handle_info({:search_changed, q}, socket) do
|
||||||
socket =
|
socket =
|
||||||
|
|
@ -604,34 +626,10 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
@impl true
|
@impl true
|
||||||
def handle_info({:field_toggled, field_string, visible}, socket) do
|
def handle_info({:field_toggled, field_string, visible}, socket) do
|
||||||
new_selection = Map.put(socket.assigns.user_field_selection, field_string, visible)
|
new_selection = Map.put(socket.assigns.user_field_selection, field_string, visible)
|
||||||
socket = update_session_field_selection(socket, new_selection)
|
|
||||||
|
|
||||||
final_selection =
|
|
||||||
FieldVisibility.merge_with_global_settings(
|
|
||||||
new_selection,
|
|
||||||
socket.assigns.settings,
|
|
||||||
socket.assigns.all_custom_fields
|
|
||||||
)
|
|
||||||
|
|
||||||
visible_member_fields =
|
|
||||||
final_selection
|
|
||||||
|> FieldVisibility.get_visible_member_fields()
|
|
||||||
|> Enum.uniq()
|
|
||||||
|
|
||||||
visible_member_fields_db = FieldVisibility.get_visible_member_fields_db(final_selection)
|
|
||||||
|
|
||||||
visible_member_fields_computed =
|
|
||||||
FieldVisibility.get_visible_member_fields_computed(final_selection)
|
|
||||||
|
|
||||||
visible_custom_fields = FieldVisibility.get_visible_custom_fields(final_selection)
|
|
||||||
|
|
||||||
socket =
|
socket =
|
||||||
socket
|
socket
|
||||||
|> assign(:user_field_selection, final_selection)
|
|> assign_field_visibility(new_selection)
|
||||||
|> assign(:member_fields_visible, visible_member_fields)
|
|
||||||
|> assign(:member_fields_visible_db, visible_member_fields_db)
|
|
||||||
|> assign(:member_fields_visible_computed, visible_member_fields_computed)
|
|
||||||
|> assign(:visible_custom_field_ids, extract_custom_field_ids(visible_custom_fields))
|
|
||||||
|> load_members()
|
|> load_members()
|
||||||
|> prepare_dynamic_cols()
|
|> prepare_dynamic_cols()
|
||||||
|> update_selection_assigns()
|
|> update_selection_assigns()
|
||||||
|
|
@ -641,35 +639,29 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
end
|
end
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def handle_info({:fields_selected, selection}, socket) do
|
def handle_info({:fields_reset}, socket) do
|
||||||
socket = update_session_field_selection(socket, selection)
|
# Reset restores the curated default column set (§1.6): recompute the default
|
||||||
|
# visibility from the global settings and re-apply it as the selection.
|
||||||
|
{cm, ie, ca} = view_flags(socket)
|
||||||
|
|
||||||
final_selection =
|
default_selection =
|
||||||
FieldVisibility.merge_with_global_settings(
|
FieldVisibility.merge_with_global_settings(
|
||||||
selection,
|
%{},
|
||||||
socket.assigns.settings,
|
socket.assigns.settings,
|
||||||
socket.assigns.all_custom_fields
|
socket.assigns.all_custom_fields,
|
||||||
|
compact_member: cm,
|
||||||
|
member_include_email: ie,
|
||||||
|
compact_address: ca
|
||||||
)
|
)
|
||||||
|
|
||||||
visible_member_fields =
|
handle_info({:fields_selected, default_selection}, socket)
|
||||||
final_selection
|
end
|
||||||
|> FieldVisibility.get_visible_member_fields()
|
|
||||||
|> Enum.uniq()
|
|
||||||
|
|
||||||
visible_member_fields_db = FieldVisibility.get_visible_member_fields_db(final_selection)
|
|
||||||
|
|
||||||
visible_member_fields_computed =
|
|
||||||
FieldVisibility.get_visible_member_fields_computed(final_selection)
|
|
||||||
|
|
||||||
visible_custom_fields = FieldVisibility.get_visible_custom_fields(final_selection)
|
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_info({:fields_selected, selection}, socket) do
|
||||||
socket =
|
socket =
|
||||||
socket
|
socket
|
||||||
|> assign(:user_field_selection, final_selection)
|
|> assign_field_visibility(selection)
|
||||||
|> assign(:member_fields_visible, visible_member_fields)
|
|
||||||
|> assign(:member_fields_visible_db, visible_member_fields_db)
|
|
||||||
|> assign(:member_fields_visible_computed, visible_member_fields_computed)
|
|
||||||
|> assign(:visible_custom_field_ids, extract_custom_field_ids(visible_custom_fields))
|
|
||||||
|> load_members()
|
|> load_members()
|
||||||
|> prepare_dynamic_cols()
|
|> prepare_dynamic_cols()
|
||||||
|> update_selection_assigns()
|
|> update_selection_assigns()
|
||||||
|
|
@ -697,18 +689,6 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
url_selection = FieldSelection.parse_from_url(params)
|
url_selection = FieldSelection.parse_from_url(params)
|
||||||
final_selection = compute_final_field_selection(fields_in_url?, url_selection, socket)
|
final_selection = compute_final_field_selection(fields_in_url?, url_selection, socket)
|
||||||
|
|
||||||
visible_member_fields =
|
|
||||||
final_selection
|
|
||||||
|> FieldVisibility.get_visible_member_fields()
|
|
||||||
|> Enum.uniq()
|
|
||||||
|
|
||||||
visible_member_fields_db = FieldVisibility.get_visible_member_fields_db(final_selection)
|
|
||||||
|
|
||||||
visible_member_fields_computed =
|
|
||||||
FieldVisibility.get_visible_member_fields_computed(final_selection)
|
|
||||||
|
|
||||||
visible_custom_fields = FieldVisibility.get_visible_custom_fields(final_selection)
|
|
||||||
|
|
||||||
socket =
|
socket =
|
||||||
socket
|
socket
|
||||||
|> maybe_update_search(params)
|
|> maybe_update_search(params)
|
||||||
|
|
@ -721,11 +701,7 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
|> maybe_update_show_current_cycle(params)
|
|> maybe_update_show_current_cycle(params)
|
||||||
|> assign(:fields_in_url?, fields_in_url?)
|
|> assign(:fields_in_url?, fields_in_url?)
|
||||||
|> assign(:query, params["query"])
|
|> assign(:query, params["query"])
|
||||||
|> assign(:user_field_selection, final_selection)
|
|> assign_visibility_derivations(final_selection)
|
||||||
|> assign(:member_fields_visible, visible_member_fields)
|
|
||||||
|> assign(:member_fields_visible_db, visible_member_fields_db)
|
|
||||||
|> assign(:member_fields_visible_computed, visible_member_fields_computed)
|
|
||||||
|> assign(:visible_custom_field_ids, extract_custom_field_ids(visible_custom_fields))
|
|
||||||
|> assign(:selected_member_id, parse_highlight_param(params["highlight"]))
|
|> assign(:selected_member_id, parse_highlight_param(params["highlight"]))
|
||||||
|
|
||||||
next_sig = build_signature(socket)
|
next_sig = build_signature(socket)
|
||||||
|
|
@ -863,8 +839,52 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
push_reload(socket, new_path)
|
push_reload(socket, new_path)
|
||||||
end
|
end
|
||||||
|
|
||||||
defp update_session_field_selection(socket, selection) do
|
# The current composite view flags; the offered column set is scoped to these.
|
||||||
assign(socket, :user_field_selection, selection)
|
defp view_flags(socket) do
|
||||||
|
{socket.assigns.compact_member, socket.assigns.member_include_email,
|
||||||
|
socket.assigns.compact_address}
|
||||||
|
end
|
||||||
|
|
||||||
|
# Merges a raw selection with the global settings scoped to the current view
|
||||||
|
# settings, then assigns it together with the visibility-derived assigns.
|
||||||
|
defp assign_field_visibility(socket, selection) do
|
||||||
|
{cm, ie, ca} = view_flags(socket)
|
||||||
|
|
||||||
|
final =
|
||||||
|
FieldVisibility.merge_with_global_settings(
|
||||||
|
selection,
|
||||||
|
socket.assigns.settings,
|
||||||
|
socket.assigns.all_custom_fields,
|
||||||
|
compact_member: cm,
|
||||||
|
member_include_email: ie,
|
||||||
|
compact_address: ca
|
||||||
|
)
|
||||||
|
|
||||||
|
assign_visibility_derivations(socket, final)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Assigns user_field_selection and every visibility-derived assign the table and
|
||||||
|
# export payload read from an already merged (offered-scoped) selection.
|
||||||
|
defp assign_visibility_derivations(socket, final_selection) do
|
||||||
|
visible_member_fields =
|
||||||
|
final_selection
|
||||||
|
|> FieldVisibility.get_visible_member_fields()
|
||||||
|
|> Enum.uniq()
|
||||||
|
|
||||||
|
visible_custom_fields = FieldVisibility.get_visible_custom_fields(final_selection)
|
||||||
|
|
||||||
|
socket
|
||||||
|
|> assign(:user_field_selection, final_selection)
|
||||||
|
|> assign(:member_fields_visible, visible_member_fields)
|
||||||
|
|> assign(
|
||||||
|
:member_fields_visible_db,
|
||||||
|
FieldVisibility.get_visible_member_fields_db(final_selection)
|
||||||
|
)
|
||||||
|
|> assign(
|
||||||
|
:member_fields_visible_computed,
|
||||||
|
FieldVisibility.get_visible_member_fields_computed(final_selection)
|
||||||
|
)
|
||||||
|
|> assign(:visible_custom_field_ids, extract_custom_field_ids(visible_custom_fields))
|
||||||
end
|
end
|
||||||
|
|
||||||
defp build_query_params(opts) when is_map(opts) do
|
defp build_query_params(opts) when is_map(opts) do
|
||||||
|
|
@ -904,8 +924,14 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
end
|
end
|
||||||
|
|
||||||
defp compute_final_field_selection(true, url_selection, socket) do
|
defp compute_final_field_selection(true, url_selection, socket) do
|
||||||
|
{cm, ie, ca} = view_flags(socket)
|
||||||
|
|
||||||
only_url =
|
only_url =
|
||||||
FieldVisibility.selection_from_url_only(url_selection, socket.assigns.all_custom_fields)
|
FieldVisibility.selection_from_url_only(url_selection, socket.assigns.all_custom_fields,
|
||||||
|
compact_member: cm,
|
||||||
|
member_include_email: ie,
|
||||||
|
compact_address: ca
|
||||||
|
)
|
||||||
|
|
||||||
visible_members = FieldVisibility.get_visible_member_fields(only_url)
|
visible_members = FieldVisibility.get_visible_member_fields(only_url)
|
||||||
visible_custom = FieldVisibility.get_visible_custom_fields(only_url)
|
visible_custom = FieldVisibility.get_visible_custom_fields(only_url)
|
||||||
|
|
@ -919,6 +945,8 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
end
|
end
|
||||||
|
|
||||||
defp compute_final_field_selection(false, url_selection, socket) do
|
defp compute_final_field_selection(false, url_selection, socket) do
|
||||||
|
{cm, ie, ca} = view_flags(socket)
|
||||||
|
|
||||||
merged =
|
merged =
|
||||||
FieldSelection.merge_sources(
|
FieldSelection.merge_sources(
|
||||||
url_selection,
|
url_selection,
|
||||||
|
|
@ -929,7 +957,10 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
FieldVisibility.merge_with_global_settings(
|
FieldVisibility.merge_with_global_settings(
|
||||||
merged,
|
merged,
|
||||||
socket.assigns.settings,
|
socket.assigns.settings,
|
||||||
socket.assigns.all_custom_fields
|
socket.assigns.all_custom_fields,
|
||||||
|
compact_member: cm,
|
||||||
|
member_include_email: ie,
|
||||||
|
compact_address: ca
|
||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -941,6 +972,181 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# The connect-info Plug.Conn (present on the initial dead render), used to read
|
||||||
|
# per-browser cookies such as the persisted view settings.
|
||||||
|
defp connect_conn(socket) do
|
||||||
|
case socket.private[:connect_info] do
|
||||||
|
%Plug.Conn{} = conn -> conn
|
||||||
|
_ -> nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# The LiveView connect params (present only on the connected mount). The client
|
||||||
|
# echoes the persisted view-settings cookie here because the connect-info map
|
||||||
|
# of a live socket does not expose cookies.
|
||||||
|
defp connect_params(socket) do
|
||||||
|
if connected?(socket), do: get_connect_params(socket), else: nil
|
||||||
|
end
|
||||||
|
|
||||||
|
# Assigns the full view-settings map plus the derived per-setting assigns the
|
||||||
|
# template renders from.
|
||||||
|
defp assign_view_settings(socket, settings) do
|
||||||
|
socket
|
||||||
|
|> assign(:view_settings, settings)
|
||||||
|
|> assign(:density, settings.density)
|
||||||
|
|> assign(:compact_member, settings.compact_member)
|
||||||
|
|> assign(:member_include_email, settings.member_include_email)
|
||||||
|
|> assign(:compact_address, settings.compact_address)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Updates a single view setting, re-derives the template assigns and persists
|
||||||
|
# the whole settings map per browser (client writes the cookie; the connected
|
||||||
|
# mount reads it back via connect params).
|
||||||
|
defp update_view_setting(socket, key, value) do
|
||||||
|
settings = Map.put(socket.assigns.view_settings, key, value)
|
||||||
|
|
||||||
|
socket
|
||||||
|
|> assign_view_settings(settings)
|
||||||
|
|> push_event("store-view-settings", %{view_settings: ViewSettings.to_json(settings)})
|
||||||
|
end
|
||||||
|
|
||||||
|
# Flips the density setting. This only re-renders the table wrapper attribute
|
||||||
|
# (row spacing token), so the streamed rows do not need to be re-rendered.
|
||||||
|
defp toggle_view_setting(socket, :density) do
|
||||||
|
new = if socket.assigns.density == :compact, do: :comfortable, else: :compact
|
||||||
|
update_view_setting(socket, :density, new)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Flips the "include email" sub-toggle. Only meaningful while the composite
|
||||||
|
# Member cell is on: it moves the email between an in-cell line (on) and a
|
||||||
|
# separate E-Mail column (off), so besides re-rendering the rows it recomputes
|
||||||
|
# which columns the manager offers and their visibility.
|
||||||
|
defp toggle_view_setting(socket, :member_include_email) do
|
||||||
|
new_value = not socket.assigns.view_settings.member_include_email
|
||||||
|
|
||||||
|
socket
|
||||||
|
|> update_view_setting(:member_include_email, new_value)
|
||||||
|
|> apply_include_email_toggle(new_value)
|
||||||
|
|> load_members()
|
||||||
|
|> prepare_dynamic_cols()
|
||||||
|
|> update_selection_assigns()
|
||||||
|
|> push_field_selection_url()
|
||||||
|
end
|
||||||
|
|
||||||
|
# Flips the compact Member field. Besides re-rendering the rows, this changes
|
||||||
|
# which columns the manager offers (composite "Name" vs. Vorname/Nachname/E-Mail,
|
||||||
|
# §7b). The email visibility carries over between the two representations,
|
||||||
|
# analogously to the "include email" sub-toggle: composite + include-email ⇄ the
|
||||||
|
# E-Mail column being visible.
|
||||||
|
defp toggle_view_setting(socket, :compact_member) do
|
||||||
|
new_value = not socket.assigns.view_settings.compact_member
|
||||||
|
email_visible? = email_currently_visible?(socket)
|
||||||
|
|
||||||
|
socket
|
||||||
|
|> update_view_setting(:compact_member, new_value)
|
||||||
|
|> sync_include_email(new_value, email_visible?)
|
||||||
|
|> apply_member_toggle(new_value, email_visible?)
|
||||||
|
|> load_members()
|
||||||
|
|> prepare_dynamic_cols()
|
||||||
|
|> update_selection_assigns()
|
||||||
|
|> push_field_selection_url()
|
||||||
|
end
|
||||||
|
|
||||||
|
# Flips the compact Address field: offers the composite "Adresse" vs. the
|
||||||
|
# separate Straße/Hausnummer/PLZ/Ort columns, making the newly relevant columns
|
||||||
|
# visible and recomputing the offered set + visibility for the new mode.
|
||||||
|
defp toggle_view_setting(socket, :compact_address) do
|
||||||
|
new_value = not socket.assigns.view_settings.compact_address
|
||||||
|
|
||||||
|
fields = if new_value, do: [:address], else: [:street, :house_number, :postal_code, :city]
|
||||||
|
|
||||||
|
socket
|
||||||
|
|> update_view_setting(:compact_address, new_value)
|
||||||
|
|> apply_composite_toggle(fields)
|
||||||
|
|> load_members()
|
||||||
|
|> prepare_dynamic_cols()
|
||||||
|
|> update_selection_assigns()
|
||||||
|
|> push_field_selection_url()
|
||||||
|
end
|
||||||
|
|
||||||
|
# Whether the member email is currently surfaced: while the composite is on,
|
||||||
|
# either folded into the cell (include_email) or as a separate E-Mail column;
|
||||||
|
# while it is off, as its own column. Read before the flip, so `compact_member`
|
||||||
|
# still holds the previous mode.
|
||||||
|
defp email_currently_visible?(socket) do
|
||||||
|
if socket.assigns.compact_member do
|
||||||
|
socket.assigns.member_include_email or :email in socket.assigns.member_fields_visible
|
||||||
|
else
|
||||||
|
:email in socket.assigns.member_fields_visible
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Turning include_email OFF surfaces the email as a separate column; make it
|
||||||
|
# visible. Turning it ON folds the email into the cell, so the column is no
|
||||||
|
# longer offered (the recompute drops it).
|
||||||
|
defp apply_include_email_toggle(socket, false = _include_email) do
|
||||||
|
selection = Map.put(socket.assigns.user_field_selection, "email", true)
|
||||||
|
recompute_offered(socket, selection)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp apply_include_email_toggle(socket, true = _include_email),
|
||||||
|
do: recompute_offered(socket, socket.assigns.user_field_selection)
|
||||||
|
|
||||||
|
# When switching to the composite, mirror the email state onto the include-email
|
||||||
|
# sub-toggle so the email keeps being surfaced; when switching away, the
|
||||||
|
# sub-toggle is not applicable.
|
||||||
|
defp sync_include_email(socket, true = _new_compact, email_visible?),
|
||||||
|
do: update_view_setting(socket, :member_include_email, email_visible?)
|
||||||
|
|
||||||
|
defp sync_include_email(socket, false = _new_compact, _email_visible?), do: socket
|
||||||
|
|
||||||
|
# Makes the now-relevant Member columns visible for the new mode. Turning the
|
||||||
|
# composite on surfaces "Name" (with the email either folded via include_email
|
||||||
|
# or kept as a separate column); turning it off surfaces Vorname/Nachname and
|
||||||
|
# carries the prior email visibility onto the E-Mail column.
|
||||||
|
defp apply_member_toggle(socket, true = _new_compact, email_visible?) do
|
||||||
|
selection =
|
||||||
|
socket.assigns.user_field_selection
|
||||||
|
|> Map.put("name", true)
|
||||||
|
|> Map.put("email", email_visible?)
|
||||||
|
|
||||||
|
recompute_offered(socket, selection)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp apply_member_toggle(socket, false = _new_compact, email_visible?) do
|
||||||
|
selection =
|
||||||
|
socket.assigns.user_field_selection
|
||||||
|
|> Map.put("first_name", true)
|
||||||
|
|> Map.put("last_name", true)
|
||||||
|
|> Map.put("email", email_visible?)
|
||||||
|
|
||||||
|
recompute_offered(socket, selection)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Sets the given columns visible, then recomputes the offered set + visibility.
|
||||||
|
defp apply_composite_toggle(socket, fields) do
|
||||||
|
selection =
|
||||||
|
Enum.reduce(fields, socket.assigns.user_field_selection, fn field, acc ->
|
||||||
|
Map.put(acc, Atom.to_string(field), true)
|
||||||
|
end)
|
||||||
|
|
||||||
|
recompute_offered(socket, selection)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Recomputes the offered column list and the derived visibility for the current
|
||||||
|
# mode. `update_view_setting` has already updated the compact_* assigns, so
|
||||||
|
# `view_flags/1` reflects the new mode here.
|
||||||
|
defp recompute_offered(socket, selection) do
|
||||||
|
{cm, ie, ca} = view_flags(socket)
|
||||||
|
|
||||||
|
socket
|
||||||
|
|> assign(
|
||||||
|
:all_available_fields,
|
||||||
|
FieldVisibility.get_offered_fields(socket.assigns.all_custom_fields, cm, ie, ca)
|
||||||
|
)
|
||||||
|
|> assign_field_visibility(selection)
|
||||||
|
end
|
||||||
|
|
||||||
# Parses optional "highlight" URL param (member id for selected row styling). Returns nil if missing or invalid.
|
# Parses optional "highlight" URL param (member id for selected row styling). Returns nil if missing or invalid.
|
||||||
defp parse_highlight_param(nil), do: nil
|
defp parse_highlight_param(nil), do: nil
|
||||||
defp parse_highlight_param(""), do: nil
|
defp parse_highlight_param(""), do: nil
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@
|
||||||
<.live_component
|
<.live_component
|
||||||
module={MvWeb.Components.BulkActionsDropdown}
|
module={MvWeb.Components.BulkActionsDropdown}
|
||||||
id="bulk-actions-dropdown"
|
id="bulk-actions-dropdown"
|
||||||
open={@bulk_actions_open}
|
|
||||||
export_payload_json={@export_payload_json}
|
export_payload_json={@export_payload_json}
|
||||||
selected_count={@selected_count}
|
selected_count={@selected_count}
|
||||||
scope={@scope}
|
scope={@scope}
|
||||||
|
|
@ -72,6 +71,7 @@
|
||||||
</span>
|
</span>
|
||||||
</.button>
|
</.button>
|
||||||
</.tooltip>
|
</.tooltip>
|
||||||
|
<div class="ml-auto flex items-center gap-4">
|
||||||
<.live_component
|
<.live_component
|
||||||
module={MvWeb.Components.FieldVisibilityDropdownComponent}
|
module={MvWeb.Components.FieldVisibilityDropdownComponent}
|
||||||
id="field-visibility-dropdown"
|
id="field-visibility-dropdown"
|
||||||
|
|
@ -79,6 +79,15 @@
|
||||||
custom_fields={@all_custom_fields}
|
custom_fields={@all_custom_fields}
|
||||||
selected_fields={@user_field_selection}
|
selected_fields={@user_field_selection}
|
||||||
/>
|
/>
|
||||||
|
<.live_component
|
||||||
|
module={MvWeb.Components.ViewSettingsDropdownComponent}
|
||||||
|
id="view-settings-dropdown"
|
||||||
|
density={@density}
|
||||||
|
compact_member={@compact_member}
|
||||||
|
member_include_email={@member_include_email}
|
||||||
|
compact_address={@compact_address}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<%!-- Polite live region: present on first render (before it is filled) so
|
<%!-- Polite live region: present on first render (before it is filled) so
|
||||||
|
|
@ -103,6 +112,8 @@
|
||||||
role="region"
|
role="region"
|
||||||
aria-label={gettext("Members table")}
|
aria-label={gettext("Members table")}
|
||||||
aria-busy={to_string(@loading?)}
|
aria-busy={to_string(@loading?)}
|
||||||
|
data-density={@density}
|
||||||
|
tabindex="0"
|
||||||
>
|
>
|
||||||
<.table
|
<.table
|
||||||
id="members"
|
id="members"
|
||||||
|
|
@ -122,6 +133,7 @@
|
||||||
dynamic_cols={@dynamic_cols}
|
dynamic_cols={@dynamic_cols}
|
||||||
sort_field={@sort_field}
|
sort_field={@sort_field}
|
||||||
sort_order={@sort_order}
|
sort_order={@sort_order}
|
||||||
|
size_class={if @density == :compact, do: "table-xs", else: "table-md"}
|
||||||
>
|
>
|
||||||
|
|
||||||
<!-- <:col :let={member} label="Id">{member.id}</:col> -->
|
<!-- <:col :let={member} label="Id">{member.id}</:col> -->
|
||||||
|
|
@ -130,10 +142,14 @@
|
||||||
col_click={&MvWeb.MemberLive.Index.checkbox_column_click/1}
|
col_click={&MvWeb.MemberLive.Index.checkbox_column_click/1}
|
||||||
label={
|
label={
|
||||||
~H"""
|
~H"""
|
||||||
<.input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
|
id="select-all-checkbox"
|
||||||
name="select_all"
|
name="select_all"
|
||||||
|
class="checkbox checkbox-sm"
|
||||||
phx-click="select_all"
|
phx-click="select_all"
|
||||||
|
phx-hook="IndeterminateCheckbox"
|
||||||
|
data-indeterminate={to_string(@selected_count > 0 and @selected_count < @total_count)}
|
||||||
checked={@total_count > 0 and @selected_count == @total_count}
|
checked={@total_count > 0 and @selected_count == @total_count}
|
||||||
aria-label={gettext("Select all members")}
|
aria-label={gettext("Select all members")}
|
||||||
role="checkbox"
|
role="checkbox"
|
||||||
|
|
@ -141,9 +157,10 @@
|
||||||
"""
|
"""
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<.input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
name={member.id}
|
name={member.id}
|
||||||
|
class="checkbox checkbox-sm"
|
||||||
checked={MapSet.member?(@selected_members, member.id)}
|
checked={MapSet.member?(@selected_members, member.id)}
|
||||||
aria-label={gettext("Select member")}
|
aria-label={gettext("Select member")}
|
||||||
role="checkbox"
|
role="checkbox"
|
||||||
|
|
@ -151,8 +168,21 @@
|
||||||
</:col>
|
</:col>
|
||||||
<:col
|
<:col
|
||||||
:let={member}
|
:let={member}
|
||||||
:if={:name in @member_fields_visible}
|
:if={:name in @member_fields_visible and @compact_member}
|
||||||
|
sort_field={:name}
|
||||||
|
label={
|
||||||
|
~H"""
|
||||||
|
<.live_component
|
||||||
|
module={MvWeb.Components.SortHeaderComponent}
|
||||||
|
id={:sort_name}
|
||||||
|
field={:name}
|
||||||
label={gettext("Name")}
|
label={gettext("Name")}
|
||||||
|
sort_field={@sort_field}
|
||||||
|
sort_order={@sort_order}
|
||||||
|
sort_hint={gettext("Click to sort by last name")}
|
||||||
|
/>
|
||||||
|
"""
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<% full_name =
|
<% full_name =
|
||||||
[member.first_name, member.last_name]
|
[member.first_name, member.last_name]
|
||||||
|
|
@ -163,7 +193,8 @@
|
||||||
{full_name}
|
{full_name}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
:if={member.email not in [nil, ""]}
|
:if={@member_include_email and member.email not in [nil, ""]}
|
||||||
|
data-testid="member-name-email"
|
||||||
class="opacity-70 truncate"
|
class="opacity-70 truncate"
|
||||||
title={member.email}
|
title={member.email}
|
||||||
>
|
>
|
||||||
|
|
@ -171,29 +202,10 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</:col>
|
</:col>
|
||||||
<:col
|
|
||||||
:let={member}
|
|
||||||
:if={:address in @member_fields_visible}
|
|
||||||
label={gettext("Address")}
|
|
||||||
>
|
|
||||||
<% line1 =
|
|
||||||
[member.street, member.house_number]
|
|
||||||
|> Enum.reject(&(&1 in [nil, ""]))
|
|
||||||
|> Enum.join(" ") %>
|
|
||||||
<% line2 =
|
|
||||||
[member.postal_code, member.city]
|
|
||||||
|> Enum.reject(&(&1 in [nil, ""]))
|
|
||||||
|> Enum.join(" ") %>
|
|
||||||
<.maybe_value value={line1 <> line2} empty_sr_text={gettext("No address")}>
|
|
||||||
<div data-testid="member-address">
|
|
||||||
<div class="truncate" title={line1}>{line1}</div>
|
|
||||||
<div class="opacity-70 truncate" title={line2}>{line2}</div>
|
|
||||||
</div>
|
|
||||||
</.maybe_value>
|
|
||||||
</:col>
|
|
||||||
<:col
|
<:col
|
||||||
:let={member}
|
:let={member}
|
||||||
:if={:first_name in @member_fields_visible}
|
:if={:first_name in @member_fields_visible}
|
||||||
|
sort_field={:first_name}
|
||||||
label={
|
label={
|
||||||
~H"""
|
~H"""
|
||||||
<.live_component
|
<.live_component
|
||||||
|
|
@ -212,6 +224,7 @@
|
||||||
<:col
|
<:col
|
||||||
:let={member}
|
:let={member}
|
||||||
:if={:last_name in @member_fields_visible}
|
:if={:last_name in @member_fields_visible}
|
||||||
|
sort_field={:last_name}
|
||||||
label={
|
label={
|
||||||
~H"""
|
~H"""
|
||||||
<.live_component
|
<.live_component
|
||||||
|
|
@ -230,6 +243,7 @@
|
||||||
<:col
|
<:col
|
||||||
:let={member}
|
:let={member}
|
||||||
:if={:email in @member_fields_visible}
|
:if={:email in @member_fields_visible}
|
||||||
|
sort_field={:email}
|
||||||
label={
|
label={
|
||||||
~H"""
|
~H"""
|
||||||
<.live_component
|
<.live_component
|
||||||
|
|
@ -245,9 +259,117 @@
|
||||||
>
|
>
|
||||||
{member.email}
|
{member.email}
|
||||||
</:col>
|
</:col>
|
||||||
|
<:col
|
||||||
|
:let={member}
|
||||||
|
:if={:address in @member_fields_visible and @compact_address}
|
||||||
|
sort_field={:address}
|
||||||
|
label={
|
||||||
|
~H"""
|
||||||
|
<.live_component
|
||||||
|
module={MvWeb.Components.SortHeaderComponent}
|
||||||
|
id={:sort_address}
|
||||||
|
field={:address}
|
||||||
|
label={gettext("Address")}
|
||||||
|
sort_field={@sort_field}
|
||||||
|
sort_order={@sort_order}
|
||||||
|
sort_hint={gettext("Click to sort by city")}
|
||||||
|
/>
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<% line1 =
|
||||||
|
[member.street, member.house_number]
|
||||||
|
|> Enum.reject(&(&1 in [nil, ""]))
|
||||||
|
|> Enum.join(" ") %>
|
||||||
|
<% line2 =
|
||||||
|
[member.postal_code, member.city]
|
||||||
|
|> Enum.reject(&(&1 in [nil, ""]))
|
||||||
|
|> Enum.join(" ") %>
|
||||||
|
<div data-testid="member-address">
|
||||||
|
<div class="truncate" title={line1}>{line1}</div>
|
||||||
|
<div class="opacity-70 truncate" title={line2}>{line2}</div>
|
||||||
|
</div>
|
||||||
|
</:col>
|
||||||
|
<:col
|
||||||
|
:let={member}
|
||||||
|
:if={:street in @member_fields_visible}
|
||||||
|
sort_field={:street}
|
||||||
|
label={
|
||||||
|
~H"""
|
||||||
|
<.live_component
|
||||||
|
module={MvWeb.Components.SortHeaderComponent}
|
||||||
|
id={:sort_street}
|
||||||
|
field={:street}
|
||||||
|
label={gettext("Street")}
|
||||||
|
sort_field={@sort_field}
|
||||||
|
sort_order={@sort_order}
|
||||||
|
/>
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{member.street}
|
||||||
|
</:col>
|
||||||
|
<:col
|
||||||
|
:let={member}
|
||||||
|
:if={:house_number in @member_fields_visible}
|
||||||
|
sort_field={:house_number}
|
||||||
|
label={
|
||||||
|
~H"""
|
||||||
|
<.live_component
|
||||||
|
module={MvWeb.Components.SortHeaderComponent}
|
||||||
|
id={:sort_house_number}
|
||||||
|
field={:house_number}
|
||||||
|
label={gettext("House Number")}
|
||||||
|
sort_field={@sort_field}
|
||||||
|
sort_order={@sort_order}
|
||||||
|
/>
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{member.house_number}
|
||||||
|
</:col>
|
||||||
|
<:col
|
||||||
|
:let={member}
|
||||||
|
:if={:postal_code in @member_fields_visible}
|
||||||
|
sort_field={:postal_code}
|
||||||
|
label={
|
||||||
|
~H"""
|
||||||
|
<.live_component
|
||||||
|
module={MvWeb.Components.SortHeaderComponent}
|
||||||
|
id={:sort_postal_code}
|
||||||
|
field={:postal_code}
|
||||||
|
label={gettext("Postal Code")}
|
||||||
|
sort_field={@sort_field}
|
||||||
|
sort_order={@sort_order}
|
||||||
|
/>
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{member.postal_code}
|
||||||
|
</:col>
|
||||||
|
<:col
|
||||||
|
:let={member}
|
||||||
|
:if={:city in @member_fields_visible}
|
||||||
|
sort_field={:city}
|
||||||
|
label={
|
||||||
|
~H"""
|
||||||
|
<.live_component
|
||||||
|
module={MvWeb.Components.SortHeaderComponent}
|
||||||
|
id={:sort_city}
|
||||||
|
field={:city}
|
||||||
|
label={gettext("City")}
|
||||||
|
sort_field={@sort_field}
|
||||||
|
sort_order={@sort_order}
|
||||||
|
/>
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{member.city}
|
||||||
|
</:col>
|
||||||
<:col
|
<:col
|
||||||
:let={member}
|
:let={member}
|
||||||
:if={:join_date in @member_fields_visible}
|
:if={:join_date in @member_fields_visible}
|
||||||
|
sort_field={:join_date}
|
||||||
label={
|
label={
|
||||||
~H"""
|
~H"""
|
||||||
<.live_component
|
<.live_component
|
||||||
|
|
@ -266,6 +388,7 @@
|
||||||
<:col
|
<:col
|
||||||
:let={member}
|
:let={member}
|
||||||
:if={:exit_date in @member_fields_visible}
|
:if={:exit_date in @member_fields_visible}
|
||||||
|
sort_field={:exit_date}
|
||||||
label={
|
label={
|
||||||
~H"""
|
~H"""
|
||||||
<.live_component
|
<.live_component
|
||||||
|
|
@ -291,6 +414,7 @@
|
||||||
<:col
|
<:col
|
||||||
:let={member}
|
:let={member}
|
||||||
:if={:country in @member_fields_visible}
|
:if={:country in @member_fields_visible}
|
||||||
|
sort_field={:country}
|
||||||
label={
|
label={
|
||||||
~H"""
|
~H"""
|
||||||
<.live_component
|
<.live_component
|
||||||
|
|
@ -306,89 +430,10 @@
|
||||||
>
|
>
|
||||||
{member.country}
|
{member.country}
|
||||||
</:col>
|
</:col>
|
||||||
<:col
|
|
||||||
:let={member}
|
|
||||||
:if={:city in @member_fields_visible}
|
|
||||||
label={
|
|
||||||
~H"""
|
|
||||||
<.live_component
|
|
||||||
module={MvWeb.Components.SortHeaderComponent}
|
|
||||||
id={:sort_city}
|
|
||||||
field={:city}
|
|
||||||
label={gettext("City")}
|
|
||||||
sort_field={@sort_field}
|
|
||||||
sort_order={@sort_order}
|
|
||||||
/>
|
|
||||||
"""
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<.maybe_value value={member.city} empty_sr_text={gettext("Not specified")}>
|
|
||||||
{member.city}
|
|
||||||
</.maybe_value>
|
|
||||||
</:col>
|
|
||||||
<:col
|
|
||||||
:let={member}
|
|
||||||
:if={:street in @member_fields_visible}
|
|
||||||
label={
|
|
||||||
~H"""
|
|
||||||
<.live_component
|
|
||||||
module={MvWeb.Components.SortHeaderComponent}
|
|
||||||
id={:sort_street}
|
|
||||||
field={:street}
|
|
||||||
label={gettext("Street")}
|
|
||||||
sort_field={@sort_field}
|
|
||||||
sort_order={@sort_order}
|
|
||||||
/>
|
|
||||||
"""
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<.maybe_value value={member.street} empty_sr_text={gettext("Not specified")}>
|
|
||||||
{member.street}
|
|
||||||
</.maybe_value>
|
|
||||||
</:col>
|
|
||||||
<:col
|
|
||||||
:let={member}
|
|
||||||
:if={:house_number in @member_fields_visible}
|
|
||||||
label={
|
|
||||||
~H"""
|
|
||||||
<.live_component
|
|
||||||
module={MvWeb.Components.SortHeaderComponent}
|
|
||||||
id={:sort_house_number}
|
|
||||||
field={:house_number}
|
|
||||||
label={gettext("House Number")}
|
|
||||||
sort_field={@sort_field}
|
|
||||||
sort_order={@sort_order}
|
|
||||||
/>
|
|
||||||
"""
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<.maybe_value value={member.house_number} empty_sr_text={gettext("Not specified")}>
|
|
||||||
{member.house_number}
|
|
||||||
</.maybe_value>
|
|
||||||
</:col>
|
|
||||||
<:col
|
|
||||||
:let={member}
|
|
||||||
:if={:postal_code in @member_fields_visible}
|
|
||||||
label={
|
|
||||||
~H"""
|
|
||||||
<.live_component
|
|
||||||
module={MvWeb.Components.SortHeaderComponent}
|
|
||||||
id={:sort_postal_code}
|
|
||||||
field={:postal_code}
|
|
||||||
label={gettext("Postal Code")}
|
|
||||||
sort_field={@sort_field}
|
|
||||||
sort_order={@sort_order}
|
|
||||||
/>
|
|
||||||
"""
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<.maybe_value value={member.postal_code} empty_sr_text={gettext("Not specified")}>
|
|
||||||
{member.postal_code}
|
|
||||||
</.maybe_value>
|
|
||||||
</:col>
|
|
||||||
<:col
|
<:col
|
||||||
:let={member}
|
:let={member}
|
||||||
:if={:membership_fee_start_date in @member_fields_visible}
|
:if={:membership_fee_start_date in @member_fields_visible}
|
||||||
|
sort_field={:membership_fee_start_date}
|
||||||
label={
|
label={
|
||||||
~H"""
|
~H"""
|
||||||
<.live_component
|
<.live_component
|
||||||
|
|
@ -407,6 +452,7 @@
|
||||||
<:col
|
<:col
|
||||||
:let={member}
|
:let={member}
|
||||||
:if={:membership_fee_type in @member_fields_visible}
|
:if={:membership_fee_type in @member_fields_visible}
|
||||||
|
sort_field={:membership_fee_type}
|
||||||
label={
|
label={
|
||||||
~H"""
|
~H"""
|
||||||
<.live_component
|
<.live_component
|
||||||
|
|
@ -443,6 +489,7 @@
|
||||||
<:col
|
<:col
|
||||||
:let={member}
|
:let={member}
|
||||||
:if={:groups in @member_fields_visible}
|
:if={:groups in @member_fields_visible}
|
||||||
|
sort_field={:groups}
|
||||||
label={
|
label={
|
||||||
~H"""
|
~H"""
|
||||||
<.live_component
|
<.live_component
|
||||||
|
|
@ -491,11 +538,17 @@
|
||||||
class="h-0"
|
class="h-0"
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
<%!-- The accent (background + top separator) and row height live on the
|
||||||
|
full-width footer cell (see #members-footer in app.css) so they span the
|
||||||
|
whole table edge-to-edge and match the active density. This inner element
|
||||||
|
stays pinned to the visible viewport width via StickyViewportWidth and is
|
||||||
|
transparent, so the cell accent shows through while the spinner/label stay
|
||||||
|
centered during horizontal scroll. --%>
|
||||||
<div
|
<div
|
||||||
id="members-loading-bar"
|
id="members-loading-bar"
|
||||||
phx-hook="StickyViewportWidth"
|
phx-hook="StickyViewportWidth"
|
||||||
data-scroll-container="members-table-guard"
|
data-scroll-container="members-table-guard"
|
||||||
class="sticky left-0 flex items-center justify-center gap-2 border-t border-base-300 bg-base-200 py-4 text-base-content/70"
|
class="sticky left-0 flex items-center justify-center gap-2 text-base-content/70"
|
||||||
data-testid="members-loading-row"
|
data-testid="members-loading-row"
|
||||||
>
|
>
|
||||||
<span class="loading loading-spinner loading-sm" aria-hidden="true"></span>
|
<span class="loading loading-spinner loading-sm" aria-hidden="true"></span>
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,21 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do
|
||||||
# Groups and membership_fee_type are also pseudo fields (not in member_fields(), displayed in the table).
|
# Groups and membership_fee_type are also pseudo fields (not in member_fields(), displayed in the table).
|
||||||
# :name and :address are composite display-only columns (Name = name + email,
|
# :name and :address are composite display-only columns (Name = name + email,
|
||||||
# Address = street/house number over postal code/city).
|
# Address = street/house number over postal code/city).
|
||||||
@pseudo_member_fields [:membership_fee_status, :membership_fee_type, :groups, :name, :address]
|
# Order mirrors the overview table / export column order (fee type before fee
|
||||||
|
# status), so the Columns dropdown and the table agree.
|
||||||
|
@pseudo_member_fields [:membership_fee_type, :membership_fee_status, :groups, :name, :address]
|
||||||
|
|
||||||
|
# The composite display columns and the constituent columns they replace. The
|
||||||
|
# column manager offers exactly one variant per group, selected by the view
|
||||||
|
# settings: the composite when the matching "compact" setting is on, otherwise
|
||||||
|
# the constituents (see `offered_member_fields/2`).
|
||||||
|
@name_composite :name
|
||||||
|
@name_constituents [:first_name, :last_name, :email]
|
||||||
|
@address_composite :address
|
||||||
|
@address_constituents [:street, :house_number, :postal_code, :city]
|
||||||
|
|
||||||
|
# Export/API may accept this as alias; must not appear in the UI options list.
|
||||||
|
@export_only_alias :payment_status
|
||||||
|
|
||||||
# Curated default-visible column set (§1.2): the columns shown on first visit
|
# Curated default-visible column set (§1.2): the columns shown on first visit
|
||||||
# when there is no persisted selection and no global override. Everything else
|
# when there is no persisted selection and no global override. Everything else
|
||||||
|
|
@ -52,8 +66,139 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do
|
||||||
@spec default_visible_fields() :: [atom()]
|
@spec default_visible_fields() :: [atom()]
|
||||||
def default_visible_fields, do: MapSet.to_list(@default_visible_fields)
|
def default_visible_fields, do: MapSet.to_list(@default_visible_fields)
|
||||||
|
|
||||||
# Export/API may accept this as alias; must not appear in the UI options list.
|
@doc """
|
||||||
@export_only_alias :payment_status
|
The curated default-visible columns for the given view settings.
|
||||||
|
|
||||||
|
In compact mode the composite `:name` / `:address` columns are visible by
|
||||||
|
default; when a composite is switched off, its constituent columns take over
|
||||||
|
as the defaults instead (§7b).
|
||||||
|
|
||||||
|
While the Member composite is compact, `include_email` decides where the email
|
||||||
|
lives: folded into the Member cell (`true`, no separate column), or as a
|
||||||
|
default-visible `:email` column (`false`).
|
||||||
|
"""
|
||||||
|
@spec default_visible_fields(boolean(), boolean(), boolean()) :: [atom()]
|
||||||
|
def default_visible_fields(compact_member, include_email, compact_address) do
|
||||||
|
compact_member
|
||||||
|
|> default_visible_set(include_email, compact_address)
|
||||||
|
|> MapSet.to_list()
|
||||||
|
end
|
||||||
|
|
||||||
|
defp default_visible_set(compact_member, include_email, compact_address) do
|
||||||
|
@default_visible_fields
|
||||||
|
|> apply_name_default(compact_member, include_email)
|
||||||
|
|> apply_group_default(compact_address, @address_composite, @address_constituents)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Compact Member cell with email folded in: only the composite is default.
|
||||||
|
defp apply_name_default(set, true = _compact, true = _include_email), do: set
|
||||||
|
|
||||||
|
# Compact Member cell without folded email: the composite plus a separate
|
||||||
|
# default-visible E-Mail column.
|
||||||
|
defp apply_name_default(set, true = _compact, false = _include_email),
|
||||||
|
do: MapSet.put(set, :email)
|
||||||
|
|
||||||
|
# Non-compact: drop the composite and make first/last name and email default.
|
||||||
|
defp apply_name_default(set, false = _compact, _include_email) do
|
||||||
|
set
|
||||||
|
|> MapSet.delete(@name_composite)
|
||||||
|
|> MapSet.union(MapSet.new(@name_constituents))
|
||||||
|
end
|
||||||
|
|
||||||
|
# Compact: the composite stays the default. Non-compact: drop the composite and
|
||||||
|
# make the constituents default-visible.
|
||||||
|
defp apply_group_default(set, true, _composite, _constituents), do: set
|
||||||
|
|
||||||
|
defp apply_group_default(set, false, composite, constituents) do
|
||||||
|
set
|
||||||
|
|> MapSet.delete(composite)
|
||||||
|
|> MapSet.union(MapSet.new(constituents))
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Member-field atoms the column manager offers for the given view settings, in
|
||||||
|
dropdown/table order.
|
||||||
|
|
||||||
|
The identity block always leads, regardless of whether each group is compact:
|
||||||
|
|
||||||
|
1. the name group — the composite `:name` when compact, otherwise its
|
||||||
|
constituents `:first_name`, `:last_name`;
|
||||||
|
2. the `:email` column, when offered as a separate column (it is folded into
|
||||||
|
the Member cell only while the composite is compact and `include_email` is
|
||||||
|
on);
|
||||||
|
3. the address group — the composite `:address` when compact, otherwise its
|
||||||
|
constituents `:street`, `:house_number`, `:postal_code`, `:city`.
|
||||||
|
|
||||||
|
Everything else follows in the configured Datenfelder order. This keeps the
|
||||||
|
name group ahead of the address group even when the name group is expanded
|
||||||
|
into `:first_name`/`:last_name` while the address stays compact.
|
||||||
|
"""
|
||||||
|
@spec offered_member_fields(boolean(), boolean(), boolean()) :: [atom()]
|
||||||
|
def offered_member_fields(compact_member, include_email, compact_address) do
|
||||||
|
name_group = if compact_member, do: [@name_composite], else: [:first_name, :last_name]
|
||||||
|
email_group = if email_offered?(compact_member, include_email), do: [:email], else: []
|
||||||
|
|
||||||
|
address_group =
|
||||||
|
if compact_address, do: [@address_composite], else: @address_constituents
|
||||||
|
|
||||||
|
leading = name_group ++ email_group ++ address_group
|
||||||
|
|
||||||
|
rest =
|
||||||
|
Enum.reject(overview_member_fields(), fn field ->
|
||||||
|
field == @export_only_alias or
|
||||||
|
hidden_variant?(field, compact_member, include_email, compact_address) or
|
||||||
|
field in leading
|
||||||
|
end)
|
||||||
|
|
||||||
|
leading ++ rest
|
||||||
|
end
|
||||||
|
|
||||||
|
# The email is a separate offered column except when it is folded into the
|
||||||
|
# compact Member cell (compact + include_email).
|
||||||
|
defp email_offered?(compact_member, include_email), do: not (compact_member and include_email)
|
||||||
|
|
||||||
|
# The variant of a composite group that is not offered in the current mode.
|
||||||
|
defp hidden_variant?(field, compact_member, include_email, compact_address) do
|
||||||
|
name_variant_hidden?(field, compact_member, include_email) or
|
||||||
|
address_variant_hidden?(field, compact_address)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Compact hides Vorname/Nachname (folded into the composite). The email is
|
||||||
|
# hidden only when it is folded into the cell (compact + include_email); with
|
||||||
|
# include_email off it stays offered as a separate column. Non-compact hides the
|
||||||
|
# composite in favour of the constituents.
|
||||||
|
defp name_variant_hidden?(@name_composite, compact_member, _include_email),
|
||||||
|
do: not compact_member
|
||||||
|
|
||||||
|
defp name_variant_hidden?(:email, compact_member, include_email),
|
||||||
|
do: compact_member and include_email
|
||||||
|
|
||||||
|
defp name_variant_hidden?(field, compact_member, _include_email)
|
||||||
|
when field in [:first_name, :last_name],
|
||||||
|
do: compact_member
|
||||||
|
|
||||||
|
defp name_variant_hidden?(_field, _compact_member, _include_email), do: false
|
||||||
|
|
||||||
|
defp address_variant_hidden?(@address_composite, compact_address),
|
||||||
|
do: not compact_address
|
||||||
|
|
||||||
|
defp address_variant_hidden?(field, compact_address)
|
||||||
|
when field in [:street, :house_number, :postal_code, :city],
|
||||||
|
do: compact_address
|
||||||
|
|
||||||
|
defp address_variant_hidden?(_field, _compact_address), do: false
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
All fields the column manager offers for the given view settings: the offered
|
||||||
|
member fields (see `offered_member_fields/3`) followed by the custom fields.
|
||||||
|
"""
|
||||||
|
@spec get_offered_fields([struct()], boolean(), boolean(), boolean()) :: [
|
||||||
|
atom() | String.t()
|
||||||
|
]
|
||||||
|
def get_offered_fields(custom_fields, compact_member, include_email, compact_address) do
|
||||||
|
custom_field_names = Enum.map(custom_fields, &"custom_field_#{&1.id}")
|
||||||
|
offered_member_fields(compact_member, include_email, compact_address) ++ custom_field_names
|
||||||
|
end
|
||||||
|
|
||||||
defp overview_member_fields do
|
defp overview_member_fields do
|
||||||
Mv.Constants.member_fields() ++ @pseudo_member_fields
|
Mv.Constants.member_fields() ++ @pseudo_member_fields
|
||||||
|
|
@ -93,8 +238,28 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do
|
||||||
String.t() => boolean()
|
String.t() => boolean()
|
||||||
}
|
}
|
||||||
def selection_from_url_only(url_selection, custom_fields) when is_map(url_selection) do
|
def selection_from_url_only(url_selection, custom_fields) when is_map(url_selection) do
|
||||||
all_fields = get_all_available_fields(custom_fields)
|
do_selection_from_url_only(url_selection, get_all_available_fields(custom_fields))
|
||||||
|
end
|
||||||
|
|
||||||
|
def selection_from_url_only(_, _), do: %{}
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Like `selection_from_url_only/2`, but scoped to the columns offered for the
|
||||||
|
given view settings (`:compact_member` / `:member_include_email` /
|
||||||
|
`:compact_address` in `opts`).
|
||||||
|
"""
|
||||||
|
@spec selection_from_url_only(%{String.t() => boolean()}, [struct()], keyword()) :: %{
|
||||||
|
String.t() => boolean()
|
||||||
|
}
|
||||||
|
def selection_from_url_only(url_selection, custom_fields, opts)
|
||||||
|
when is_map(url_selection) and is_list(opts) do
|
||||||
|
{cm, ie, ca} = view_opts(opts)
|
||||||
|
do_selection_from_url_only(url_selection, get_offered_fields(custom_fields, cm, ie, ca))
|
||||||
|
end
|
||||||
|
|
||||||
|
def selection_from_url_only(_, _, _), do: %{}
|
||||||
|
|
||||||
|
defp do_selection_from_url_only(url_selection, all_fields) do
|
||||||
Enum.reduce(all_fields, %{}, fn field, acc ->
|
Enum.reduce(all_fields, %{}, fn field, acc ->
|
||||||
field_string = field_to_string(field)
|
field_string = field_to_string(field)
|
||||||
visible = Map.get(url_selection, field_string, false)
|
visible = Map.get(url_selection, field_string, false)
|
||||||
|
|
@ -102,7 +267,13 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
def selection_from_url_only(_, _), do: %{}
|
defp view_opts(opts) do
|
||||||
|
{
|
||||||
|
Keyword.get(opts, :compact_member, true),
|
||||||
|
Keyword.get(opts, :member_include_email, false),
|
||||||
|
Keyword.get(opts, :compact_address, true)
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Merges user field selection with global settings.
|
Merges user field selection with global settings.
|
||||||
|
|
@ -135,7 +306,31 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do
|
||||||
) :: %{String.t() => boolean()}
|
) :: %{String.t() => boolean()}
|
||||||
def merge_with_global_settings(user_selection, global_settings, custom_fields) do
|
def merge_with_global_settings(user_selection, global_settings, custom_fields) do
|
||||||
all_fields = get_all_available_fields(custom_fields)
|
all_fields = get_all_available_fields(custom_fields)
|
||||||
global_visibility = get_global_visibility_map(global_settings, custom_fields)
|
do_merge(user_selection, global_settings, custom_fields, all_fields, @default_visible_fields)
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Like `merge_with_global_settings/3`, but scoped to the columns offered for the
|
||||||
|
given view settings (`:compact_member` / `:member_include_email` /
|
||||||
|
`:compact_address` in `opts`).
|
||||||
|
|
||||||
|
Only offered columns appear in the result, so a column that is not applicable
|
||||||
|
in the current mode (e.g. `:first_name` while the composite "Name" is on) never
|
||||||
|
leaks into the visible set. Defaults follow the mode via
|
||||||
|
`default_visible_fields/2`.
|
||||||
|
"""
|
||||||
|
@spec merge_with_global_settings(%{String.t() => boolean()}, map(), [struct()], keyword()) ::
|
||||||
|
%{String.t() => boolean()}
|
||||||
|
def merge_with_global_settings(user_selection, global_settings, custom_fields, opts)
|
||||||
|
when is_list(opts) do
|
||||||
|
{cm, ie, ca} = view_opts(opts)
|
||||||
|
all_fields = get_offered_fields(custom_fields, cm, ie, ca)
|
||||||
|
default_set = default_visible_set(cm, ie, ca)
|
||||||
|
do_merge(user_selection, global_settings, custom_fields, all_fields, default_set)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp do_merge(user_selection, global_settings, custom_fields, all_fields, default_set) do
|
||||||
|
global_visibility = get_global_visibility_map(global_settings, custom_fields, default_set)
|
||||||
|
|
||||||
Enum.reduce(all_fields, %{}, fn field, acc ->
|
Enum.reduce(all_fields, %{}, fn field, acc ->
|
||||||
field_string = field_to_string(field)
|
field_string = field_to_string(field)
|
||||||
|
|
@ -280,15 +475,15 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do
|
||||||
def get_visible_custom_fields(_), do: []
|
def get_visible_custom_fields(_), do: []
|
||||||
|
|
||||||
# Gets global visibility map from settings
|
# Gets global visibility map from settings
|
||||||
defp get_global_visibility_map(settings, custom_fields) do
|
defp get_global_visibility_map(settings, custom_fields, default_set) do
|
||||||
member_visibility = get_member_field_visibility_from_settings(settings)
|
member_visibility = get_member_field_visibility_from_settings(settings, default_set)
|
||||||
custom_field_visibility = get_custom_field_visibility(custom_fields)
|
custom_field_visibility = get_custom_field_visibility(custom_fields)
|
||||||
|
|
||||||
Map.merge(member_visibility, custom_field_visibility)
|
Map.merge(member_visibility, custom_field_visibility)
|
||||||
end
|
end
|
||||||
|
|
||||||
# Gets member field visibility from settings (domain fields from settings, pseudo fields default true)
|
# Gets member field visibility from settings (domain fields from settings, pseudo fields default true)
|
||||||
defp get_member_field_visibility_from_settings(settings) do
|
defp get_member_field_visibility_from_settings(settings, default_set) do
|
||||||
visibility_config =
|
visibility_config =
|
||||||
VisibilityConfig.normalize(Map.get(settings, :member_field_visibility, %{}))
|
VisibilityConfig.normalize(Map.get(settings, :member_field_visibility, %{}))
|
||||||
|
|
||||||
|
|
@ -297,13 +492,13 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do
|
||||||
domain_map =
|
domain_map =
|
||||||
Enum.reduce(domain_fields, %{}, fn field, acc ->
|
Enum.reduce(domain_fields, %{}, fn field, acc ->
|
||||||
field_string = Atom.to_string(field)
|
field_string = Atom.to_string(field)
|
||||||
default_visibility = MapSet.member?(@default_visible_fields, field)
|
default_visibility = MapSet.member?(default_set, field)
|
||||||
show_in_overview = Map.get(visibility_config, field, default_visibility)
|
show_in_overview = Map.get(visibility_config, field, default_visibility)
|
||||||
Map.put(acc, field_string, show_in_overview)
|
Map.put(acc, field_string, show_in_overview)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
Enum.reduce(@pseudo_member_fields, domain_map, fn field, acc ->
|
Enum.reduce(@pseudo_member_fields, domain_map, fn field, acc ->
|
||||||
Map.put(acc, Atom.to_string(field), MapSet.member?(@default_visible_fields, field))
|
Map.put(acc, Atom.to_string(field), MapSet.member?(default_set, field))
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
|
||||||
186
lib/mv_web/live/member_live/index/view_settings.ex
Normal file
186
lib/mv_web/live/member_live/index/view_settings.ex
Normal file
|
|
@ -0,0 +1,186 @@
|
||||||
|
defmodule MvWeb.MemberLive.Index.ViewSettings do
|
||||||
|
@moduledoc """
|
||||||
|
Resolves and persists the member-overview view settings per browser/device.
|
||||||
|
|
||||||
|
A view-settings value is a map of four keys:
|
||||||
|
|
||||||
|
* `:density` — `:compact` or `:comfortable` (the row-spacing / compact mode)
|
||||||
|
* `:compact_member` — composite "Member" cell (last+first name) vs separate
|
||||||
|
Vorname/Nachname columns
|
||||||
|
* `:member_include_email` — whether the email is surfaced (email line in the
|
||||||
|
composite cell, or a separate Email column when the member field is split)
|
||||||
|
* `:compact_address` — composite Address cell (street / postal+city) vs
|
||||||
|
separate Straße/PLZ/Ort columns
|
||||||
|
|
||||||
|
Persistence is per browser (not per account), resolved in priority order from
|
||||||
|
the LiveView connect params, the session, the request cookie, and finally the
|
||||||
|
global defaults (`defaults/0`). The chosen value is written to a long-lived
|
||||||
|
cookie by a small client-side listener; on the next connected mount the client
|
||||||
|
echoes the cookie back through the socket connect params (the connect-info map
|
||||||
|
on a live socket does not expose cookies), and on the disconnected render the
|
||||||
|
cookie is read directly from the request conn.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@cookie_name "member_view_settings"
|
||||||
|
@session_key "member_view_settings"
|
||||||
|
@connect_param "view_settings"
|
||||||
|
@cookie_max_age 365 * 24 * 60 * 60
|
||||||
|
|
||||||
|
@defaults %{
|
||||||
|
density: :compact,
|
||||||
|
compact_member: true,
|
||||||
|
member_include_email: false,
|
||||||
|
compact_address: true
|
||||||
|
}
|
||||||
|
|
||||||
|
@densities [:compact, :comfortable]
|
||||||
|
@bool_keys [:compact_member, :member_include_email, :compact_address]
|
||||||
|
|
||||||
|
@type t :: %{
|
||||||
|
density: :compact | :comfortable,
|
||||||
|
compact_member: boolean(),
|
||||||
|
member_include_email: boolean(),
|
||||||
|
compact_address: boolean()
|
||||||
|
}
|
||||||
|
|
||||||
|
@doc "The global default view settings used when nothing is stored."
|
||||||
|
@spec defaults() :: %{
|
||||||
|
density: :compact,
|
||||||
|
compact_member: true,
|
||||||
|
member_include_email: false,
|
||||||
|
compact_address: true
|
||||||
|
}
|
||||||
|
def defaults, do: @defaults
|
||||||
|
|
||||||
|
@doc "The cookie name the settings are persisted under (used by the client listener)."
|
||||||
|
@spec cookie_name() :: String.t()
|
||||||
|
def cookie_name, do: @cookie_name
|
||||||
|
|
||||||
|
@doc "The cookie max-age in seconds (365 days)."
|
||||||
|
@spec cookie_max_age() :: 31_536_000
|
||||||
|
def cookie_max_age, do: @cookie_max_age
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Parses a raw map (string or atom keys, string/boolean values) into a validated
|
||||||
|
partial settings map with atom keys. Unknown keys and invalid values are
|
||||||
|
dropped, so a caller can safely `Map.merge/2` the result over another source.
|
||||||
|
"""
|
||||||
|
@spec parse(term()) :: %{optional(atom()) => term()}
|
||||||
|
def parse(raw) when is_map(raw) do
|
||||||
|
Enum.reduce(raw, %{}, fn {key, value}, acc ->
|
||||||
|
case parse_pair(to_string(key), value) do
|
||||||
|
{k, v} -> Map.put(acc, k, v)
|
||||||
|
:error -> acc
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
def parse(_), do: %{}
|
||||||
|
|
||||||
|
defp parse_pair("density", value) do
|
||||||
|
case parse_density(value) do
|
||||||
|
nil -> :error
|
||||||
|
density -> {:density, density}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp parse_pair(key, value)
|
||||||
|
when key in ~w(compact_member member_include_email compact_address) do
|
||||||
|
case parse_bool(value) do
|
||||||
|
nil -> :error
|
||||||
|
bool -> {String.to_existing_atom(key), bool}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp parse_pair(_key, _value), do: :error
|
||||||
|
|
||||||
|
defp parse_density(value) when value in @densities, do: value
|
||||||
|
defp parse_density("compact"), do: :compact
|
||||||
|
defp parse_density("comfortable"), do: :comfortable
|
||||||
|
defp parse_density(_), do: nil
|
||||||
|
|
||||||
|
defp parse_bool(value) when is_boolean(value), do: value
|
||||||
|
defp parse_bool("true"), do: true
|
||||||
|
defp parse_bool("false"), do: false
|
||||||
|
defp parse_bool(_), do: nil
|
||||||
|
|
||||||
|
@doc "Reads partial settings from the LiveView session map."
|
||||||
|
@spec get_from_session(map()) :: %{optional(atom()) => term()}
|
||||||
|
def get_from_session(session) when is_map(session),
|
||||||
|
do: parse_json(Map.get(session, @session_key))
|
||||||
|
|
||||||
|
def get_from_session(_), do: %{}
|
||||||
|
|
||||||
|
@doc "Reads partial settings from the request cookie header of a connect-info conn."
|
||||||
|
@spec get_from_cookie(Plug.Conn.t() | nil) :: %{optional(atom()) => term()}
|
||||||
|
def get_from_cookie(%Plug.Conn{} = conn) do
|
||||||
|
case Plug.Conn.get_req_header(conn, "cookie") do
|
||||||
|
[cookie_header | _rest] ->
|
||||||
|
cookie_header
|
||||||
|
|> parse_cookie_header()
|
||||||
|
|> Map.get(@cookie_name)
|
||||||
|
|> parse_json()
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
%{}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def get_from_cookie(_), do: %{}
|
||||||
|
|
||||||
|
@doc "Reads partial settings from the LiveView connect params (raw JSON string)."
|
||||||
|
@spec get_from_connect_params(map() | nil) :: %{optional(atom()) => term()}
|
||||||
|
def get_from_connect_params(params) when is_map(params),
|
||||||
|
do: parse_json(Map.get(params, @connect_param))
|
||||||
|
|
||||||
|
def get_from_connect_params(_), do: %{}
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Resolves the effective view settings for a mount, merging the sources by
|
||||||
|
priority (connect params > session > cookie), falling back per key to the
|
||||||
|
global defaults.
|
||||||
|
"""
|
||||||
|
@spec resolve(map(), Plug.Conn.t() | nil, map() | nil) :: t()
|
||||||
|
def resolve(session, conn, connect_params \\ nil) do
|
||||||
|
@defaults
|
||||||
|
|> Map.merge(get_from_cookie(conn))
|
||||||
|
|> Map.merge(get_from_session(session))
|
||||||
|
|> Map.merge(get_from_connect_params(connect_params))
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc "Serializes settings to a JSON string for the client-side cookie writer."
|
||||||
|
@spec to_json(map()) :: String.t()
|
||||||
|
def to_json(settings) when is_map(settings) do
|
||||||
|
settings
|
||||||
|
|> Map.take([:density | @bool_keys])
|
||||||
|
|> Map.new(fn
|
||||||
|
{:density, density} -> {:density, to_string(density)}
|
||||||
|
{key, value} -> {key, value}
|
||||||
|
end)
|
||||||
|
|> Jason.encode!()
|
||||||
|
end
|
||||||
|
|
||||||
|
defp parse_json(nil), do: %{}
|
||||||
|
|
||||||
|
defp parse_json(json) when is_binary(json) do
|
||||||
|
case Jason.decode(json) do
|
||||||
|
{:ok, decoded} -> parse(decoded)
|
||||||
|
_ -> %{}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp parse_json(_), do: %{}
|
||||||
|
|
||||||
|
# Parses a cookie header string into a name => value map.
|
||||||
|
defp parse_cookie_header(cookie_header) when is_binary(cookie_header) do
|
||||||
|
cookie_header
|
||||||
|
|> String.split(";")
|
||||||
|
|> Enum.map(&String.trim/1)
|
||||||
|
|> Enum.map(&String.split(&1, "=", parts: 2))
|
||||||
|
|> Enum.reduce(%{}, fn
|
||||||
|
[key, value], acc -> Map.put(acc, key, URI.decode(value))
|
||||||
|
[key], acc -> Map.put(acc, key, "")
|
||||||
|
_, acc -> acc
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -4,11 +4,25 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
|
||||||
|
|
||||||
# The curated default columns hide the individual name/address sort headers,
|
# The curated default columns hide the individual name/address sort headers,
|
||||||
# so these component tests make every sortable field visible via ?fields=.
|
# so these component tests make every sortable field visible via ?fields=.
|
||||||
|
# The individual name/address constituents are only *offered* when the compact
|
||||||
|
# Member / Address view settings are off, so the session opts them out of the
|
||||||
|
# composite columns before selecting the fields via ?fields=.
|
||||||
@cols "first_name,email,street,house_number,postal_code,city,country,join_date"
|
@cols "first_name,email,street,house_number,postal_code,city,country,join_date"
|
||||||
|
|
||||||
|
# Disables the compact Member/Address composites so first_name/last_name/email
|
||||||
|
# and street/house_number/postal_code/city are offered as individual sortable
|
||||||
|
# columns (persisted per browser via the view-settings session key).
|
||||||
|
defp non_compact(conn) do
|
||||||
|
Plug.Conn.put_session(
|
||||||
|
conn,
|
||||||
|
"member_view_settings",
|
||||||
|
~s({"compact_member":false,"compact_address":false})
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
describe "rendering" do
|
describe "rendering" do
|
||||||
test "renders with correct attributes", %{conn: conn} do
|
test "renders with correct attributes", %{conn: conn} do
|
||||||
conn = conn_with_oidc_user(conn)
|
conn = conn |> conn_with_oidc_user() |> non_compact()
|
||||||
{:ok, view, _html} = live(conn, "/members?fields=#{@cols}")
|
{:ok, view, _html} = live(conn, "/members?fields=#{@cols}")
|
||||||
|
|
||||||
# Test that the component renders with correct attributes
|
# Test that the component renders with correct attributes
|
||||||
|
|
@ -18,7 +32,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
test "renders all sortable headers", %{conn: conn} do
|
test "renders all sortable headers", %{conn: conn} do
|
||||||
conn = conn_with_oidc_user(conn)
|
conn = conn |> conn_with_oidc_user() |> non_compact()
|
||||||
{:ok, view, _html} = live(conn, "/members?fields=#{@cols}")
|
{:ok, view, _html} = live(conn, "/members?fields=#{@cols}")
|
||||||
|
|
||||||
sortable_fields = [
|
sortable_fields = [
|
||||||
|
|
@ -38,7 +52,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
test "renders correct labels", %{conn: conn} do
|
test "renders correct labels", %{conn: conn} do
|
||||||
conn = conn_with_oidc_user(conn)
|
conn = conn |> conn_with_oidc_user() |> non_compact()
|
||||||
{:ok, view, _html} = live(conn, "/members?fields=#{@cols}")
|
{:ok, view, _html} = live(conn, "/members?fields=#{@cols}")
|
||||||
|
|
||||||
# Test specific labels
|
# Test specific labels
|
||||||
|
|
@ -50,7 +64,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
|
||||||
|
|
||||||
describe "sort icons" do
|
describe "sort icons" do
|
||||||
test "shows neutral icon for specific field when not sorted", %{conn: conn} do
|
test "shows neutral icon for specific field when not sorted", %{conn: conn} do
|
||||||
conn = conn_with_oidc_user(conn)
|
conn = conn |> conn_with_oidc_user() |> non_compact()
|
||||||
{:ok, view, _html} = live(conn, "/members?fields=#{@cols}")
|
{:ok, view, _html} = live(conn, "/members?fields=#{@cols}")
|
||||||
|
|
||||||
# The neutral icon has the opcity class we can test for
|
# The neutral icon has the opcity class we can test for
|
||||||
|
|
@ -62,7 +76,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
test "shows ascending icon for specific field when sorted ascending", %{conn: conn} do
|
test "shows ascending icon for specific field when sorted ascending", %{conn: conn} do
|
||||||
conn = conn_with_oidc_user(conn)
|
conn = conn |> conn_with_oidc_user() |> non_compact()
|
||||||
|
|
||||||
{:ok, view, html} =
|
{:ok, view, html} =
|
||||||
live(conn, "/members?fields=#{@cols}&query=&sort_field=city&sort_order=asc")
|
live(conn, "/members?fields=#{@cols}&query=&sort_field=city&sort_order=asc")
|
||||||
|
|
@ -85,7 +99,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
test "shows descending icon for specific field when sorted descending", %{conn: conn} do
|
test "shows descending icon for specific field when sorted descending", %{conn: conn} do
|
||||||
conn = conn_with_oidc_user(conn)
|
conn = conn |> conn_with_oidc_user() |> non_compact()
|
||||||
|
|
||||||
{:ok, _view, html} =
|
{:ok, _view, html} =
|
||||||
live(conn, "/members?fields=#{@cols}&query=&sort_field=email&sort_order=desc")
|
live(conn, "/members?fields=#{@cols}&query=&sort_field=email&sort_order=desc")
|
||||||
|
|
@ -99,7 +113,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
test "multiple fields can have different icon states", %{conn: conn} do
|
test "multiple fields can have different icon states", %{conn: conn} do
|
||||||
conn = conn_with_oidc_user(conn)
|
conn = conn |> conn_with_oidc_user() |> non_compact()
|
||||||
|
|
||||||
{:ok, view, _html} =
|
{:ok, view, _html} =
|
||||||
live(conn, "/members?fields=#{@cols}&query=&sort_field=city&sort_order=asc")
|
live(conn, "/members?fields=#{@cols}&query=&sort_field=city&sort_order=asc")
|
||||||
|
|
@ -118,7 +132,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
test "icon state changes correctly when clicking different fields", %{conn: conn} do
|
test "icon state changes correctly when clicking different fields", %{conn: conn} do
|
||||||
conn = conn_with_oidc_user(conn)
|
conn = conn |> conn_with_oidc_user() |> non_compact()
|
||||||
{:ok, view, _html} = live(conn, "/members?fields=#{@cols}")
|
{:ok, view, _html} = live(conn, "/members?fields=#{@cols}")
|
||||||
|
|
||||||
# Start: all fields neutral except first name as default
|
# Start: all fields neutral except first name as default
|
||||||
|
|
@ -146,7 +160,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
test "specific field shows correct icon for each sort state", %{conn: conn} do
|
test "specific field shows correct icon for each sort state", %{conn: conn} do
|
||||||
conn = conn_with_oidc_user(conn)
|
conn = conn |> conn_with_oidc_user() |> non_compact()
|
||||||
|
|
||||||
# Test EMAIL field specifically
|
# Test EMAIL field specifically
|
||||||
{:ok, view, html_asc} =
|
{:ok, view, html_asc} =
|
||||||
|
|
@ -167,7 +181,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
test "icon distribution shows exactly one active sort icon", %{conn: conn} do
|
test "icon distribution shows exactly one active sort icon", %{conn: conn} do
|
||||||
conn = conn_with_oidc_user(conn)
|
conn = conn |> conn_with_oidc_user() |> non_compact()
|
||||||
|
|
||||||
# Test neutral state - only one field should have active sort icon
|
# Test neutral state - only one field should have active sort icon
|
||||||
{:ok, _view, html_neutral} = live(conn, "/members?fields=#{@cols}")
|
{:ok, _view, html_neutral} = live(conn, "/members?fields=#{@cols}")
|
||||||
|
|
@ -204,7 +218,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
|
||||||
|
|
||||||
describe "accessibility" do
|
describe "accessibility" do
|
||||||
test "sets aria-label correctly for unsorted state", %{conn: conn} do
|
test "sets aria-label correctly for unsorted state", %{conn: conn} do
|
||||||
conn = conn_with_oidc_user(conn)
|
conn = conn |> conn_with_oidc_user() |> non_compact()
|
||||||
{:ok, view, _html} = live(conn, "/members?fields=#{@cols}")
|
{:ok, view, _html} = live(conn, "/members?fields=#{@cols}")
|
||||||
|
|
||||||
# Check aria-label for unsorted state
|
# Check aria-label for unsorted state
|
||||||
|
|
@ -212,7 +226,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
test "sets aria-label correctly for ascending sort", %{conn: conn} do
|
test "sets aria-label correctly for ascending sort", %{conn: conn} do
|
||||||
conn = conn_with_oidc_user(conn)
|
conn = conn |> conn_with_oidc_user() |> non_compact()
|
||||||
|
|
||||||
{:ok, view, _html} =
|
{:ok, view, _html} =
|
||||||
live(conn, "/members?fields=#{@cols}&sort_field=first_name&sort_order=asc")
|
live(conn, "/members?fields=#{@cols}&sort_field=first_name&sort_order=asc")
|
||||||
|
|
@ -222,7 +236,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
test "sets aria-label correctly for descending sort", %{conn: conn} do
|
test "sets aria-label correctly for descending sort", %{conn: conn} do
|
||||||
conn = conn_with_oidc_user(conn)
|
conn = conn |> conn_with_oidc_user() |> non_compact()
|
||||||
|
|
||||||
{:ok, view, _html} =
|
{:ok, view, _html} =
|
||||||
live(conn, "/members?fields=#{@cols}&sort_field=first_name&sort_order=desc")
|
live(conn, "/members?fields=#{@cols}&sort_field=first_name&sort_order=desc")
|
||||||
|
|
@ -232,7 +246,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
test "includes tooltip with correct aria-label", %{conn: conn} do
|
test "includes tooltip with correct aria-label", %{conn: conn} do
|
||||||
conn = conn_with_oidc_user(conn)
|
conn = conn |> conn_with_oidc_user() |> non_compact()
|
||||||
|
|
||||||
{:ok, view, _html} =
|
{:ok, view, _html} =
|
||||||
live(conn, "/members?fields=#{@cols}&sort_field=first_name&sort_order=asc")
|
live(conn, "/members?fields=#{@cols}&sort_field=first_name&sort_order=asc")
|
||||||
|
|
@ -243,7 +257,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
test "aria-labels work for all sortable fields", %{conn: conn} do
|
test "aria-labels work for all sortable fields", %{conn: conn} do
|
||||||
conn = conn_with_oidc_user(conn)
|
conn = conn |> conn_with_oidc_user() |> non_compact()
|
||||||
{:ok, view, _html} = live(conn, "/members?fields=#{@cols}&sort_field=email&sort_order=desc")
|
{:ok, view, _html} = live(conn, "/members?fields=#{@cols}&sort_field=email&sort_order=desc")
|
||||||
|
|
||||||
# Test aria-labels for different fields
|
# Test aria-labels for different fields
|
||||||
|
|
@ -260,7 +274,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
|
||||||
|
|
||||||
describe "component behavior" do
|
describe "component behavior" do
|
||||||
test "clicking triggers sort event on parent LiveView", %{conn: conn} do
|
test "clicking triggers sort event on parent LiveView", %{conn: conn} do
|
||||||
conn = conn_with_oidc_user(conn)
|
conn = conn |> conn_with_oidc_user() |> non_compact()
|
||||||
{:ok, view, _html} = live(conn, "/members?fields=#{@cols}")
|
{:ok, view, _html} = live(conn, "/members?fields=#{@cols}")
|
||||||
|
|
||||||
# Click on the first name sort header
|
# Click on the first name sort header
|
||||||
|
|
@ -273,7 +287,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
test "component handles different field types correctly", %{conn: conn} do
|
test "component handles different field types correctly", %{conn: conn} do
|
||||||
conn = conn_with_oidc_user(conn)
|
conn = conn |> conn_with_oidc_user() |> non_compact()
|
||||||
{:ok, view, _html} = live(conn, "/members?fields=#{@cols}")
|
{:ok, view, _html} = live(conn, "/members?fields=#{@cols}")
|
||||||
|
|
||||||
# Test that different field types render correctly
|
# Test that different field types render correctly
|
||||||
|
|
@ -285,7 +299,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
|
||||||
|
|
||||||
describe "edge cases" do
|
describe "edge cases" do
|
||||||
test "handles invalid sort field gracefully", %{conn: conn} do
|
test "handles invalid sort field gracefully", %{conn: conn} do
|
||||||
conn = conn_with_oidc_user(conn)
|
conn = conn |> conn_with_oidc_user() |> non_compact()
|
||||||
|
|
||||||
{:ok, view, html} =
|
{:ok, view, html} =
|
||||||
live(conn, "/members?fields=#{@cols}&sort_field=invalid_field&sort_order=asc")
|
live(conn, "/members?fields=#{@cols}&sort_field=invalid_field&sort_order=asc")
|
||||||
|
|
@ -296,7 +310,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
test "handles invalid sort order gracefully", %{conn: conn} do
|
test "handles invalid sort order gracefully", %{conn: conn} do
|
||||||
conn = conn_with_oidc_user(conn)
|
conn = conn |> conn_with_oidc_user() |> non_compact()
|
||||||
|
|
||||||
{:ok, view, html} =
|
{:ok, view, html} =
|
||||||
live(conn, "/members?fields=#{@cols}&sort_field=first_name&sort_order=invalid")
|
live(conn, "/members?fields=#{@cols}&sort_field=first_name&sort_order=invalid")
|
||||||
|
|
@ -307,7 +321,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
test "handles empty sort parameters", %{conn: conn} do
|
test "handles empty sort parameters", %{conn: conn} do
|
||||||
conn = conn_with_oidc_user(conn)
|
conn = conn |> conn_with_oidc_user() |> non_compact()
|
||||||
{:ok, view, html} = live(conn, "/members?fields=#{@cols}&sort_field=&sort_order=")
|
{:ok, view, html} = live(conn, "/members?fields=#{@cols}&sort_field=&sort_order=")
|
||||||
|
|
||||||
# Should show neutral icons
|
# Should show neutral icons
|
||||||
|
|
@ -318,7 +332,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
|
||||||
|
|
||||||
describe "icon state transitions" do
|
describe "icon state transitions" do
|
||||||
test "icon changes when sorting state changes", %{conn: conn} do
|
test "icon changes when sorting state changes", %{conn: conn} do
|
||||||
conn = conn_with_oidc_user(conn)
|
conn = conn |> conn_with_oidc_user() |> non_compact()
|
||||||
{:ok, view, _html} = live(conn, "/members?fields=#{@cols}")
|
{:ok, view, _html} = live(conn, "/members?fields=#{@cols}")
|
||||||
|
|
||||||
# Start with neutral state
|
# Start with neutral state
|
||||||
|
|
@ -334,7 +348,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
test "multiple fields can be tested for icon states", %{conn: conn} do
|
test "multiple fields can be tested for icon states", %{conn: conn} do
|
||||||
conn = conn_with_oidc_user(conn)
|
conn = conn |> conn_with_oidc_user() |> non_compact()
|
||||||
{:ok, view, html} = live(conn, "/members?fields=#{@cols}&sort_field=email&sort_order=desc")
|
{:ok, view, html} = live(conn, "/members?fields=#{@cols}&sort_field=email&sort_order=desc")
|
||||||
|
|
||||||
# Email should be active (descending)
|
# Email should be active (descending)
|
||||||
|
|
|
||||||
|
|
@ -201,6 +201,56 @@ defmodule MvWeb.MemberLive.Index.FieldVisibilityTest do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe "offered_member_fields/3 ordering (name group, then email, then address group, then rest)" do
|
||||||
|
test "compact member + compact address: Name, E-Mail, Adresse lead" do
|
||||||
|
result = FieldVisibility.offered_member_fields(true, false, true)
|
||||||
|
|
||||||
|
assert Enum.take(result, 3) == [:name, :email, :address]
|
||||||
|
# the composite constituents are not offered while their composite is on
|
||||||
|
refute :first_name in result
|
||||||
|
refute :street in result
|
||||||
|
# the Datenfelder-ordered rest trails the identity block
|
||||||
|
assert index_of(result, :address) < index_of(result, :join_date)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "compact member with email folded in: no separate E-Mail column" do
|
||||||
|
result = FieldVisibility.offered_member_fields(true, true, true)
|
||||||
|
|
||||||
|
assert Enum.take(result, 2) == [:name, :address]
|
||||||
|
refute :email in result
|
||||||
|
end
|
||||||
|
|
||||||
|
test "non-compact member + compact address: Vorname/Nachname/E-Mail before Adresse" do
|
||||||
|
result = FieldVisibility.offered_member_fields(false, false, true)
|
||||||
|
|
||||||
|
# the reported bug: the composite address must not jump ahead of the
|
||||||
|
# expanded name constituents
|
||||||
|
assert Enum.take(result, 4) == [:first_name, :last_name, :email, :address]
|
||||||
|
assert index_of(result, :address) < index_of(result, :join_date)
|
||||||
|
end
|
||||||
|
|
||||||
|
test "non-compact member + non-compact address: name, email, then the address block, then rest" do
|
||||||
|
result = FieldVisibility.offered_member_fields(false, false, false)
|
||||||
|
|
||||||
|
assert Enum.take(result, 7) == [
|
||||||
|
:first_name,
|
||||||
|
:last_name,
|
||||||
|
:email,
|
||||||
|
:street,
|
||||||
|
:house_number,
|
||||||
|
:postal_code,
|
||||||
|
:city
|
||||||
|
]
|
||||||
|
|
||||||
|
# join_date and later Datenfelder columns never precede the address block
|
||||||
|
assert index_of(result, :city) < index_of(result, :join_date)
|
||||||
|
refute :name in result
|
||||||
|
refute :address in result
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp index_of(list, elem), do: Enum.find_index(list, &(&1 == elem))
|
||||||
|
|
||||||
describe "get_visible_fields/1" do
|
describe "get_visible_fields/1" do
|
||||||
test "returns only fields with true visibility" do
|
test "returns only fields with true visibility" do
|
||||||
selection = %{
|
selection = %{
|
||||||
|
|
|
||||||
109
test/mv_web/live/member_live/index/view_settings_test.exs
Normal file
109
test/mv_web/live/member_live/index/view_settings_test.exs
Normal file
|
|
@ -0,0 +1,109 @@
|
||||||
|
defmodule MvWeb.MemberLive.Index.ViewSettingsTest do
|
||||||
|
@moduledoc """
|
||||||
|
§1.20 / §1.5 — the member-overview view settings (density + the compact
|
||||||
|
member/address field toggles) and their per-browser persistence chain
|
||||||
|
(connect params > session > cookie > global default).
|
||||||
|
"""
|
||||||
|
use ExUnit.Case, async: true
|
||||||
|
|
||||||
|
alias MvWeb.MemberLive.Index.ViewSettings
|
||||||
|
|
||||||
|
test "defaults are all-compact with the email line off" do
|
||||||
|
assert ViewSettings.defaults() == %{
|
||||||
|
density: :compact,
|
||||||
|
compact_member: true,
|
||||||
|
member_include_email: false,
|
||||||
|
compact_address: true
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "parse/1" do
|
||||||
|
test "reads a partial map with string keys and string/bool values" do
|
||||||
|
assert ViewSettings.parse(%{
|
||||||
|
"density" => "comfortable",
|
||||||
|
"compact_member" => false,
|
||||||
|
"member_include_email" => true
|
||||||
|
}) == %{
|
||||||
|
density: :comfortable,
|
||||||
|
compact_member: false,
|
||||||
|
member_include_email: true
|
||||||
|
}
|
||||||
|
end
|
||||||
|
|
||||||
|
test "ignores unknown keys and invalid values" do
|
||||||
|
assert ViewSettings.parse(%{"density" => "huge", "bogus" => true}) == %{}
|
||||||
|
end
|
||||||
|
|
||||||
|
test "returns empty map for non-maps" do
|
||||||
|
assert ViewSettings.parse(nil) == %{}
|
||||||
|
assert ViewSettings.parse("x") == %{}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "get_from_* sources" do
|
||||||
|
test "session JSON string" do
|
||||||
|
session = %{"member_view_settings" => ~s({"density":"comfortable"})}
|
||||||
|
assert ViewSettings.get_from_session(session) == %{density: :comfortable}
|
||||||
|
assert ViewSettings.get_from_session(%{}) == %{}
|
||||||
|
end
|
||||||
|
|
||||||
|
test "cookie header" do
|
||||||
|
json = ~s({"compact_address":false})
|
||||||
|
conn = %Plug.Conn{req_headers: [{"cookie", "member_view_settings=#{URI.encode(json)}"}]}
|
||||||
|
assert ViewSettings.get_from_cookie(conn) == %{compact_address: false}
|
||||||
|
assert ViewSettings.get_from_cookie(%Plug.Conn{req_headers: []}) == %{}
|
||||||
|
assert ViewSettings.get_from_cookie(nil) == %{}
|
||||||
|
end
|
||||||
|
|
||||||
|
test "connect params (raw JSON string under view_settings)" do
|
||||||
|
params = %{"view_settings" => ~s({"density":"comfortable","compact_member":false})}
|
||||||
|
|
||||||
|
assert ViewSettings.get_from_connect_params(params) == %{
|
||||||
|
density: :comfortable,
|
||||||
|
compact_member: false
|
||||||
|
}
|
||||||
|
|
||||||
|
assert ViewSettings.get_from_connect_params(nil) == %{}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
describe "resolve/3 priority connect_params > session > cookie > default" do
|
||||||
|
test "falls back to defaults when nothing is stored" do
|
||||||
|
assert ViewSettings.resolve(%{}, nil, nil) == ViewSettings.defaults()
|
||||||
|
end
|
||||||
|
|
||||||
|
test "cookie overrides default, session overrides cookie, connect params win" do
|
||||||
|
cookie_json = ~s({"density":"comfortable","compact_member":false,"compact_address":false})
|
||||||
|
|
||||||
|
conn = %Plug.Conn{
|
||||||
|
req_headers: [{"cookie", "member_view_settings=#{URI.encode(cookie_json)}"}]
|
||||||
|
}
|
||||||
|
|
||||||
|
session = %{"member_view_settings" => ~s({"compact_member":true})}
|
||||||
|
connect_params = %{"view_settings" => ~s({"density":"compact"})}
|
||||||
|
|
||||||
|
assert ViewSettings.resolve(session, conn, connect_params) == %{
|
||||||
|
# from connect params (highest)
|
||||||
|
density: :compact,
|
||||||
|
# from session (overrides cookie)
|
||||||
|
compact_member: true,
|
||||||
|
# from cookie
|
||||||
|
compact_address: false,
|
||||||
|
# default
|
||||||
|
member_include_email: false
|
||||||
|
}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
test "to_json/1 round-trips through parse/1" do
|
||||||
|
settings = %{
|
||||||
|
density: :comfortable,
|
||||||
|
compact_member: false,
|
||||||
|
member_include_email: true,
|
||||||
|
compact_address: false
|
||||||
|
}
|
||||||
|
|
||||||
|
assert settings |> ViewSettings.to_json() |> Jason.decode!() |> ViewSettings.parse() ==
|
||||||
|
settings
|
||||||
|
end
|
||||||
|
end
|
||||||
78
test/mv_web/member_live/index_a11y_hardening_test.exs
Normal file
78
test/mv_web/member_live/index_a11y_hardening_test.exs
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
defmodule MvWeb.MemberLive.IndexA11yHardeningTest do
|
||||||
|
@moduledoc """
|
||||||
|
§3.5 — WCAG 2.2 AA posture: ≥24px interactive targets in both densities,
|
||||||
|
scroll-margin on rows so a focused row is not obscured by the sticky header,
|
||||||
|
select-all `indeterminate` for partial selection, and reflow markup keeping the
|
||||||
|
table in its own focusable scroll region.
|
||||||
|
"""
|
||||||
|
use MvWeb.ConnCase, async: false
|
||||||
|
|
||||||
|
import Phoenix.LiveViewTest
|
||||||
|
|
||||||
|
alias Mv.Helpers.SystemActor
|
||||||
|
|
||||||
|
@moduletag :ui
|
||||||
|
|
||||||
|
setup %{conn: conn} do
|
||||||
|
actor = SystemActor.get_system_actor()
|
||||||
|
|
||||||
|
members =
|
||||||
|
for i <- 1..3 do
|
||||||
|
{:ok, m} =
|
||||||
|
Mv.Membership.create_member(
|
||||||
|
%{first_name: "Wcag#{i}", last_name: "Row", email: "wcag#{i}@example.com"},
|
||||||
|
actor: actor
|
||||||
|
)
|
||||||
|
|
||||||
|
m
|
||||||
|
end
|
||||||
|
|
||||||
|
%{conn: conn_with_oidc_user(conn), members: members}
|
||||||
|
end
|
||||||
|
|
||||||
|
test "rows reserve scroll-margin under the sticky header", %{conn: conn} do
|
||||||
|
{:ok, _view, html} = live(conn, ~p"/members")
|
||||||
|
assert html =~ "scroll-mt-16"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "the table lives in its own focusable scroll region that reflows", %{conn: conn} do
|
||||||
|
{:ok, _view, html} = live(conn, ~p"/members")
|
||||||
|
|
||||||
|
# Focusable region (1.4.10: table scrolls within its own focusable region).
|
||||||
|
assert html =~ ~r/data-testid="members-table-scroll"[^>]*tabindex="0"/
|
||||||
|
# The toolbar reflows to one column on narrow viewports.
|
||||||
|
assert html =~ "flex-wrap"
|
||||||
|
end
|
||||||
|
|
||||||
|
test "interactive row targets meet the >=24px minimum in both densities", %{conn: conn} do
|
||||||
|
{:ok, view, _html} = live(conn, ~p"/members")
|
||||||
|
|
||||||
|
# Compact (default) and comfortable both keep the >=24px checkbox target.
|
||||||
|
assert has_element?(view, "input[type='checkbox'].min-h-6.min-w-6")
|
||||||
|
|
||||||
|
# Switch to comfortable via the view-settings dropdown.
|
||||||
|
view |> element("[data-testid='view-settings-button']") |> render_click()
|
||||||
|
view |> element("[data-testid='view-setting-density']") |> render_click()
|
||||||
|
assert has_element?(view, "input[type='checkbox'].min-h-6.min-w-6")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "select-all reflects indeterminate for a partial selection", %{
|
||||||
|
conn: conn,
|
||||||
|
members: members
|
||||||
|
} do
|
||||||
|
{:ok, view, _html} = live(conn, ~p"/members")
|
||||||
|
|
||||||
|
# No selection: not indeterminate.
|
||||||
|
assert has_element?(view, "#select-all-checkbox[data-indeterminate='false']")
|
||||||
|
|
||||||
|
# Select one of several -> partial -> indeterminate.
|
||||||
|
[m | _] = members
|
||||||
|
render_click(view, "select_member", %{"id" => m.id})
|
||||||
|
assert has_element?(view, "#select-all-checkbox[data-indeterminate='true']")
|
||||||
|
|
||||||
|
# Select all -> fully checked, not indeterminate.
|
||||||
|
view |> element("[phx-click='select_all']") |> render_click()
|
||||||
|
assert has_element?(view, "#select-all-checkbox[data-indeterminate='false']")
|
||||||
|
assert has_element?(view, "#select-all-checkbox[checked]")
|
||||||
|
end
|
||||||
|
end
|
||||||
76
test/mv_web/member_live/index_column_manager_test.exs
Normal file
76
test/mv_web/member_live/index_column_manager_test.exs
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
defmodule MvWeb.MemberLive.IndexColumnManagerTest do
|
||||||
|
@moduledoc """
|
||||||
|
§1.6 — The column manager sits in the filter toolbar, offers a per-column
|
||||||
|
visibility toggle plus All and None controls styled as buttons (not links),
|
||||||
|
and applies changes while preserving the existing persistence chain.
|
||||||
|
"""
|
||||||
|
use MvWeb.ConnCase, async: false
|
||||||
|
|
||||||
|
import Phoenix.LiveViewTest
|
||||||
|
|
||||||
|
alias Mv.Helpers.SystemActor
|
||||||
|
|
||||||
|
setup %{conn: conn} do
|
||||||
|
{:ok, _} =
|
||||||
|
Mv.Membership.create_member(
|
||||||
|
%{first_name: "Manager", last_name: "Columns", email: "manager@example.com"},
|
||||||
|
actor: SystemActor.get_system_actor()
|
||||||
|
)
|
||||||
|
|
||||||
|
%{conn: conn_with_oidc_user(conn)}
|
||||||
|
end
|
||||||
|
|
||||||
|
test "column manager trigger sits alongside the filter controls", %{conn: conn} do
|
||||||
|
{:ok, view, _html} = live(conn, ~p"/members")
|
||||||
|
|
||||||
|
# Both the filter panel trigger and the column-manager trigger render in the
|
||||||
|
# same toolbar.
|
||||||
|
assert has_element?(view, ~s(button[aria-label="Filter members"]))
|
||||||
|
assert has_element?(view, "button[aria-controls='field-visibility-menu']")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "All and None are buttons, not links, and per-column toggles are checkboxes", %{conn: conn} do
|
||||||
|
{:ok, view, _html} = live(conn, ~p"/members")
|
||||||
|
|
||||||
|
view |> element("button[aria-controls='field-visibility-menu']") |> render_click()
|
||||||
|
|
||||||
|
# All / None render as buttons with button styling.
|
||||||
|
assert has_element?(view, "button.btn[phx-click='select_all']", "All")
|
||||||
|
assert has_element?(view, "button.btn[phx-click='select_none']", "None")
|
||||||
|
refute has_element?(view, "a[phx-click='select_all']")
|
||||||
|
refute has_element?(view, "a[phx-click='select_none']")
|
||||||
|
|
||||||
|
# Per-column visibility toggles are accessible checkbox menu items.
|
||||||
|
assert has_element?(view, "button[role='menuitemcheckbox'][phx-value-item='name']")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "toggling a column applies and is reflected in the URL (persistence)", %{conn: conn} do
|
||||||
|
{:ok, view, _html} = live(conn, ~p"/members")
|
||||||
|
|
||||||
|
view |> element("button[aria-controls='field-visibility-menu']") |> render_click()
|
||||||
|
view |> element("button[phx-value-item='join_date']") |> render_click()
|
||||||
|
|
||||||
|
# The selection is pushed to the URL fields param so it survives reloads.
|
||||||
|
path = assert_patch(view)
|
||||||
|
assert path =~ "fields="
|
||||||
|
refute has_element?(view, "[data-testid='join_date']")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "a reset button restores the curated default column set", %{conn: conn} do
|
||||||
|
{:ok, view, _html} = live(conn, ~p"/members")
|
||||||
|
|
||||||
|
view |> element("button[aria-controls='field-visibility-menu']") |> render_click()
|
||||||
|
|
||||||
|
# Reset is a button styled like All/None, sitting next to them, with a tooltip.
|
||||||
|
assert has_element?(view, "button.btn[phx-click='reset_fields']")
|
||||||
|
|
||||||
|
# Hide a curated-default column, then reset restores it.
|
||||||
|
view |> element("button[phx-value-item='join_date']") |> render_click()
|
||||||
|
assert_patch(view)
|
||||||
|
refute has_element?(view, "[data-testid='join_date']")
|
||||||
|
|
||||||
|
view |> element("button[phx-click='reset_fields']") |> render_click()
|
||||||
|
assert_patch(view)
|
||||||
|
assert has_element?(view, "[data-testid='join_date']")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
defmodule MvWeb.MemberLive.IndexDefaultColumnsTest do
|
defmodule MvWeb.MemberLive.IndexDefaultColumnsTest do
|
||||||
@moduledoc """
|
@moduledoc """
|
||||||
§1.2 — With no persisted column selection, exactly the curated default columns
|
§1.2 — With no persisted column selection, exactly the curated default columns
|
||||||
are visible: selection checkbox, Name (name + email), Address (composite),
|
are visible: selection checkbox, Name (composite, no in-cell email line),
|
||||||
fee type, fee status, groups, join date.
|
E-Mail (its own column while the compact Member field keeps the email line off),
|
||||||
|
Address (composite), fee type, fee status, groups, join date.
|
||||||
"""
|
"""
|
||||||
use MvWeb.ConnCase, async: false
|
use MvWeb.ConnCase, async: false
|
||||||
|
|
||||||
|
|
@ -34,6 +35,8 @@ defmodule MvWeb.MemberLive.IndexDefaultColumnsTest do
|
||||||
# Composite Name + Address cells.
|
# Composite Name + Address cells.
|
||||||
assert has_element?(view, "[data-testid='member-name']")
|
assert has_element?(view, "[data-testid='member-name']")
|
||||||
assert has_element?(view, "[data-testid='member-address']")
|
assert has_element?(view, "[data-testid='member-address']")
|
||||||
|
# E-Mail is its own column by default (compact Member field, email line off).
|
||||||
|
assert has_element?(view, "[data-testid='email']")
|
||||||
# Fee type, fee status, groups, join date headers.
|
# Fee type, fee status, groups, join date headers.
|
||||||
assert has_element?(view, "[data-testid='membership_fee_type']")
|
assert has_element?(view, "[data-testid='membership_fee_type']")
|
||||||
assert has_element?(view, "[data-testid='join_date']")
|
assert has_element?(view, "[data-testid='join_date']")
|
||||||
|
|
@ -44,7 +47,9 @@ defmodule MvWeb.MemberLive.IndexDefaultColumnsTest do
|
||||||
test "the individual name/address sub-fields are hidden by default", %{conn: conn} do
|
test "the individual name/address sub-fields are hidden by default", %{conn: conn} do
|
||||||
{:ok, view, _html} = live(conn, ~p"/members")
|
{:ok, view, _html} = live(conn, ~p"/members")
|
||||||
|
|
||||||
for field <- ~w(first_name last_name email city street house_number postal_code country) do
|
# The composite Name/Address cells replace their constituents by default.
|
||||||
|
# E-Mail is excluded here: it is a default-visible column of its own.
|
||||||
|
for field <- ~w(first_name last_name city street house_number postal_code country) do
|
||||||
refute has_element?(view, "[data-testid='#{field}']")
|
refute has_element?(view, "[data-testid='#{field}']")
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
73
test/mv_web/member_live/index_density_test.exs
Normal file
73
test/mv_web/member_live/index_density_test.exs
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
defmodule MvWeb.MemberLive.IndexDensityTest do
|
||||||
|
@moduledoc """
|
||||||
|
§1.4 — The density toggle switches the table row-spacing token and reflects the
|
||||||
|
active density in the control state.
|
||||||
|
§1.5 — Density persists across reload per browser (session/cookie), with the
|
||||||
|
global default applying when nothing is stored.
|
||||||
|
"""
|
||||||
|
use MvWeb.ConnCase, async: false
|
||||||
|
|
||||||
|
import Phoenix.LiveViewTest
|
||||||
|
|
||||||
|
alias Mv.Helpers.SystemActor
|
||||||
|
|
||||||
|
setup %{conn: conn} do
|
||||||
|
{:ok, _} =
|
||||||
|
Mv.Membership.create_member(
|
||||||
|
%{first_name: "Dense", last_name: "Row", email: "dense@example.com"},
|
||||||
|
actor: SystemActor.get_system_actor()
|
||||||
|
)
|
||||||
|
|
||||||
|
%{conn: conn_with_oidc_user(conn)}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp open_view_settings(view) do
|
||||||
|
view |> element("[data-testid='view-settings-button']") |> render_click()
|
||||||
|
view
|
||||||
|
end
|
||||||
|
|
||||||
|
test "defaults to compact when nothing is stored", %{conn: conn} do
|
||||||
|
{:ok, view, _html} = live(conn, ~p"/members")
|
||||||
|
|
||||||
|
assert has_element?(view, "[data-testid='members-table-scroll'][data-density='compact']")
|
||||||
|
# The compact-mode toggle reflects the active (compact) state.
|
||||||
|
open_view_settings(view)
|
||||||
|
assert has_element?(view, "[data-testid='view-setting-density'][aria-checked='true']")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "toggle switches the density token and the control state", %{conn: conn} do
|
||||||
|
{:ok, view, _html} = live(conn, ~p"/members")
|
||||||
|
|
||||||
|
open_view_settings(view)
|
||||||
|
view |> element("[data-testid='view-setting-density']") |> render_click()
|
||||||
|
|
||||||
|
assert has_element?(view, "[data-testid='members-table-scroll'][data-density='comfortable']")
|
||||||
|
assert has_element?(view, "[data-testid='view-setting-density'][aria-checked='false']")
|
||||||
|
|
||||||
|
# Toggling again returns to compact.
|
||||||
|
view |> element("[data-testid='view-setting-density']") |> render_click()
|
||||||
|
assert has_element?(view, "[data-testid='members-table-scroll'][data-density='compact']")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "restores a persisted density from the session on reload", %{conn: conn} do
|
||||||
|
conn =
|
||||||
|
Plug.Test.init_test_session(conn, %{
|
||||||
|
"member_view_settings" => ~s({"density":"comfortable"})
|
||||||
|
})
|
||||||
|
|
||||||
|
{:ok, view, _html} = live(conn, ~p"/members")
|
||||||
|
|
||||||
|
assert has_element?(view, "[data-testid='members-table-scroll'][data-density='comfortable']")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "restores a persisted density on the connected mount via connect params", %{conn: conn} do
|
||||||
|
# Faithful reload round-trip: the connected mount cannot read the cookie
|
||||||
|
# (the live socket's connect-info map has no cookies), so the client echoes
|
||||||
|
# the persisted value through connect params. This is the path that was
|
||||||
|
# broken in the R1 accept (cookie written but not restored on mount).
|
||||||
|
conn = put_connect_params(conn, %{"view_settings" => ~s({"density":"comfortable"})})
|
||||||
|
{:ok, view, _html} = live(conn, ~p"/members")
|
||||||
|
|
||||||
|
assert has_element?(view, "[data-testid='members-table-scroll'][data-density='comfortable']")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -22,6 +22,17 @@ defmodule MvWeb.MemberLive.IndexFieldVisibilityTest do
|
||||||
|
|
||||||
require Ash.Query
|
require Ash.Query
|
||||||
|
|
||||||
|
# Disables the compact Member/Address composites (per-browser view setting) so
|
||||||
|
# the individual constituent columns (first_name/last_name/email, street/…) are
|
||||||
|
# offered by the column manager and available via ?fields=.
|
||||||
|
defp non_compact(conn) do
|
||||||
|
Plug.Conn.put_session(
|
||||||
|
conn,
|
||||||
|
"member_view_settings",
|
||||||
|
~s({"compact_member":false,"compact_address":false})
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
setup do
|
setup do
|
||||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
|
||||||
|
|
@ -112,7 +123,9 @@ defmodule MvWeb.MemberLive.IndexFieldVisibilityTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
test "displays all member fields in dropdown", %{conn: conn} do
|
test "displays all member fields in dropdown", %{conn: conn} do
|
||||||
conn = conn_with_oidc_user(conn)
|
# The individual name/address constituents are only offered when the
|
||||||
|
# compact Member/Address composites are off, so opt out of them first.
|
||||||
|
conn = conn |> conn_with_oidc_user() |> non_compact()
|
||||||
{:ok, view, _html} = live(conn, "/members")
|
{:ok, view, _html} = live(conn, "/members")
|
||||||
|
|
||||||
# Open dropdown
|
# Open dropdown
|
||||||
|
|
@ -148,9 +161,9 @@ defmodule MvWeb.MemberLive.IndexFieldVisibilityTest do
|
||||||
conn = conn_with_oidc_user(conn)
|
conn = conn_with_oidc_user(conn)
|
||||||
{:ok, view, _html} = live(conn, "/members")
|
{:ok, view, _html} = live(conn, "/members")
|
||||||
|
|
||||||
# The curated Name column carries the email, so it is visible initially.
|
# The curated Name column carries the member names, so they are visible.
|
||||||
html = render(view)
|
html = render(view)
|
||||||
assert html =~ "alice@example.com"
|
assert html =~ "Anderson"
|
||||||
|
|
||||||
# Open dropdown and hide the Name column
|
# Open dropdown and hide the Name column
|
||||||
view
|
view
|
||||||
|
|
@ -161,10 +174,10 @@ defmodule MvWeb.MemberLive.IndexFieldVisibilityTest do
|
||||||
|> element("button[phx-click='select_item'][phx-value-item='name']")
|
|> element("button[phx-click='select_item'][phx-value-item='name']")
|
||||||
|> render_click()
|
|> render_click()
|
||||||
|
|
||||||
# The Name column (and the email it carries) is no longer visible
|
# The Name column (and the names it carries) is no longer visible
|
||||||
html = render(view)
|
html = render(view)
|
||||||
refute html =~ "alice@example.com"
|
refute html =~ "Anderson"
|
||||||
refute html =~ "bob@example.com"
|
refute html =~ "Brown"
|
||||||
end
|
end
|
||||||
|
|
||||||
test "hiding custom field removes it from display", %{conn: conn, custom_field: custom_field} do
|
test "hiding custom field removes it from display", %{conn: conn, custom_field: custom_field} do
|
||||||
|
|
@ -298,8 +311,8 @@ defmodule MvWeb.MemberLive.IndexFieldVisibilityTest do
|
||||||
conn = conn_with_oidc_user(conn)
|
conn = conn_with_oidc_user(conn)
|
||||||
{:ok, _view, html} = live(conn, "/members")
|
{:ok, _view, html} = live(conn, "/members")
|
||||||
|
|
||||||
# All fields should be visible by default
|
# The curated Name and Address columns are visible by default.
|
||||||
assert html =~ "alice@example.com"
|
assert html =~ "Anderson"
|
||||||
assert html =~ "Main St"
|
assert html =~ "Main St"
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -309,7 +322,7 @@ defmodule MvWeb.MemberLive.IndexFieldVisibilityTest do
|
||||||
conn = conn_with_oidc_user(conn)
|
conn = conn_with_oidc_user(conn)
|
||||||
{:ok, view, _html} = live(conn, "/members")
|
{:ok, view, _html} = live(conn, "/members")
|
||||||
|
|
||||||
# Hide the curated Name column (which carries the email) via dropdown
|
# Hide the curated Name column (which carries the names) via dropdown
|
||||||
view
|
view
|
||||||
|> element("button[aria-controls='field-visibility-menu']")
|
|> element("button[aria-controls='field-visibility-menu']")
|
||||||
|> render_click()
|
|> render_click()
|
||||||
|
|
@ -319,7 +332,7 @@ defmodule MvWeb.MemberLive.IndexFieldVisibilityTest do
|
||||||
|> render_click()
|
|> render_click()
|
||||||
|
|
||||||
html = render(view)
|
html = render(view)
|
||||||
refute html =~ "alice@example.com"
|
refute html =~ "Anderson"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|
@ -328,16 +341,16 @@ defmodule MvWeb.MemberLive.IndexFieldVisibilityTest do
|
||||||
conn = conn_with_oidc_user(conn)
|
conn = conn_with_oidc_user(conn)
|
||||||
{:ok, _view, html} = live(conn, "/members?fields=")
|
{:ok, _view, html} = live(conn, "/members?fields=")
|
||||||
|
|
||||||
# Should fall back to global settings
|
# Should fall back to global settings (curated Name column visible)
|
||||||
assert html =~ "alice@example.com"
|
assert html =~ "Anderson"
|
||||||
end
|
end
|
||||||
|
|
||||||
test "handles invalid field names in URL", %{conn: conn} do
|
test "handles invalid field names in URL", %{conn: conn} do
|
||||||
conn = conn_with_oidc_user(conn)
|
conn = conn_with_oidc_user(conn)
|
||||||
{:ok, _view, html} = live(conn, "/members?fields=invalid_field,another_invalid")
|
{:ok, _view, html} = live(conn, "/members?fields=invalid_field,another_invalid")
|
||||||
|
|
||||||
# Should ignore invalid fields and use defaults
|
# Should ignore invalid fields and use defaults (curated Name column visible)
|
||||||
assert html =~ "alice@example.com"
|
assert html =~ "Anderson"
|
||||||
end
|
end
|
||||||
|
|
||||||
test "handles custom field that doesn't exist", %{conn: conn} do
|
test "handles custom field that doesn't exist", %{conn: conn} do
|
||||||
|
|
@ -429,9 +442,9 @@ defmodule MvWeb.MemberLive.IndexFieldVisibilityTest do
|
||||||
conn = conn_with_oidc_user(conn)
|
conn = conn_with_oidc_user(conn)
|
||||||
{:ok, view, _html} = live(conn, "/members")
|
{:ok, view, _html} = live(conn, "/members")
|
||||||
|
|
||||||
# The curated Name column carries the email, so it is visible initially.
|
# The curated Name column carries the names, so they are visible initially.
|
||||||
html = render(view)
|
html = render(view)
|
||||||
assert html =~ "alice@example.com"
|
assert html =~ "Anderson"
|
||||||
|
|
||||||
# Open dropdown
|
# Open dropdown
|
||||||
view
|
view
|
||||||
|
|
@ -443,9 +456,9 @@ defmodule MvWeb.MemberLive.IndexFieldVisibilityTest do
|
||||||
|> element("button[phx-click='select_item'][phx-value-item='name']")
|
|> element("button[phx-click='select_item'][phx-value-item='name']")
|
||||||
|> render_keydown(%{key: "Enter"})
|
|> render_keydown(%{key: "Enter"})
|
||||||
|
|
||||||
# The Name column (and the email it carries) is no longer visible
|
# The Name column (and the names it carries) is no longer visible
|
||||||
html = render(view)
|
html = render(view)
|
||||||
refute html =~ "alice@example.com"
|
refute html =~ "Anderson"
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,16 @@ defmodule MvWeb.MemberLive.IndexGroupsUrlParamsTest do
|
||||||
|
|
||||||
require Ash.Query
|
require Ash.Query
|
||||||
|
|
||||||
|
# Disables the compact Member composite (per-browser view setting) so the
|
||||||
|
# individual first_name column is offered and selectable via ?fields=.
|
||||||
|
defp non_compact(conn) do
|
||||||
|
Plug.Conn.put_session(
|
||||||
|
conn,
|
||||||
|
"member_view_settings",
|
||||||
|
~s({"compact_member":false,"compact_address":false})
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
setup do
|
setup do
|
||||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||||
|
|
||||||
|
|
@ -122,7 +132,7 @@ defmodule MvWeb.MemberLive.IndexGroupsUrlParamsTest do
|
||||||
member1: member1,
|
member1: member1,
|
||||||
group1: group1
|
group1: group1
|
||||||
} do
|
} do
|
||||||
conn = conn_with_oidc_user(conn)
|
conn = conn |> conn_with_oidc_user() |> non_compact()
|
||||||
|
|
||||||
{:ok, view, html} =
|
{:ok, view, html} =
|
||||||
live(
|
live(
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,9 @@ defmodule MvWeb.MemberLive.IndexMemberFieldsDisplayTest do
|
||||||
conn = conn_with_oidc_user(conn)
|
conn = conn_with_oidc_user(conn)
|
||||||
{:ok, _view, html} = live(conn, "/members")
|
{:ok, _view, html} = live(conn, "/members")
|
||||||
|
|
||||||
|
# The composite Member cell carries first + last name; the in-cell email
|
||||||
|
# line stays off by default, but the email is surfaced in its own column
|
||||||
|
# (compact Member field, include-email off).
|
||||||
for m <- [m1, m2], field <- [m.first_name, m.last_name, m.email] do
|
for m <- [m1, m2], field <- [m.first_name, m.last_name, m.email] do
|
||||||
assert html =~ field
|
assert html =~ field
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,19 @@ defmodule MvWeb.MemberLive.IndexTest do
|
||||||
alias Mv.Membership.CustomFieldValue
|
alias Mv.Membership.CustomFieldValue
|
||||||
alias MvWeb.MemberLive.Index, as: MemberIndex
|
alias MvWeb.MemberLive.Index, as: MemberIndex
|
||||||
|
|
||||||
|
# Disables the compact Member/Address composites (per-browser view setting) so
|
||||||
|
# the individual constituent columns (first_name/last_name, street/…) are
|
||||||
|
# offered by the column manager and selectable via ?fields=. The composite
|
||||||
|
# E-Mail column is offered even in compact mode, so email-only tests do not
|
||||||
|
# need this.
|
||||||
|
defp non_compact(conn) do
|
||||||
|
Plug.Conn.put_session(
|
||||||
|
conn,
|
||||||
|
"member_view_settings",
|
||||||
|
~s({"compact_member":false,"compact_address":false})
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
describe "desktop layout: scroll container and sticky table header" do
|
describe "desktop layout: scroll container and sticky table header" do
|
||||||
@describetag :ui
|
@describetag :ui
|
||||||
|
|
||||||
|
|
@ -155,9 +168,11 @@ defmodule MvWeb.MemberLive.IndexTest do
|
||||||
describe "sorting integration" do
|
describe "sorting integration" do
|
||||||
@describetag :ui
|
@describetag :ui
|
||||||
# The curated default columns no longer expose the individual name/address
|
# The curated default columns no longer expose the individual name/address
|
||||||
# sort headers, so these tests make the relevant column visible via ?fields=.
|
# sort headers. The composite E-Mail column is offered even in compact mode
|
||||||
# That puts a `fields` param on every push_patch, hence the relaxed
|
# (so email-only tests just add it via ?fields=), but the name/address
|
||||||
# substring assertions instead of exact patch strings.
|
# constituents are only offered once their composite view setting is off —
|
||||||
|
# those tests additionally opt out via `non_compact/1`. Either way a `fields`
|
||||||
|
# param rides on every push_patch, hence the relaxed substring assertions.
|
||||||
test "clicking a column header toggles sort order and updates the URL", %{conn: conn} do
|
test "clicking a column header toggles sort order and updates the URL", %{conn: conn} do
|
||||||
conn = conn_with_oidc_user(conn)
|
conn = conn_with_oidc_user(conn)
|
||||||
{:ok, view, _html} = live(conn, "/members?fields=email")
|
{:ok, view, _html} = live(conn, "/members?fields=email")
|
||||||
|
|
@ -183,7 +198,7 @@ defmodule MvWeb.MemberLive.IndexTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
test "clicking different column header resets order to ascending", %{conn: conn} do
|
test "clicking different column header resets order to ascending", %{conn: conn} do
|
||||||
conn = conn_with_oidc_user(conn)
|
conn = conn |> conn_with_oidc_user() |> non_compact()
|
||||||
|
|
||||||
{:ok, view, _html} =
|
{:ok, view, _html} =
|
||||||
live(conn, "/members?fields=first_name,email&sort_field=email&sort_order=desc")
|
live(conn, "/members?fields=first_name,email&sort_field=email&sort_order=desc")
|
||||||
|
|
@ -199,7 +214,7 @@ defmodule MvWeb.MemberLive.IndexTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
test "all sortable columns work correctly", %{conn: conn} do
|
test "all sortable columns work correctly", %{conn: conn} do
|
||||||
conn = conn_with_oidc_user(conn)
|
conn = conn |> conn_with_oidc_user() |> non_compact()
|
||||||
|
|
||||||
{:ok, view, _html} =
|
{:ok, view, _html} =
|
||||||
live(
|
live(
|
||||||
|
|
@ -275,7 +290,7 @@ defmodule MvWeb.MemberLive.IndexTest do
|
||||||
end
|
end
|
||||||
|
|
||||||
test "handle_params handles invalid sort field gracefully", %{conn: conn} do
|
test "handle_params handles invalid sort field gracefully", %{conn: conn} do
|
||||||
conn = conn_with_oidc_user(conn)
|
conn = conn |> conn_with_oidc_user() |> non_compact()
|
||||||
|
|
||||||
{:ok, view, _html} =
|
{:ok, view, _html} =
|
||||||
live(conn, "/members?fields=first_name&query=&sort_field=invalid_field&sort_order=asc")
|
live(conn, "/members?fields=first_name&query=&sort_field=invalid_field&sort_order=asc")
|
||||||
|
|
|
||||||
102
test/mv_web/member_live/index_view_settings_test.exs
Normal file
102
test/mv_web/member_live/index_view_settings_test.exs
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
defmodule MvWeb.MemberLive.IndexViewSettingsTest do
|
||||||
|
@moduledoc """
|
||||||
|
§1.20 — the view-settings dropdown toggles: the composite "Member" and address
|
||||||
|
fields, the "include email" sub-toggle and their effect on the rendered
|
||||||
|
columns. Turning a composite off surfaces the underlying separate columns.
|
||||||
|
§1.2 — email is not in the Member cell by default.
|
||||||
|
"""
|
||||||
|
use MvWeb.ConnCase, async: false
|
||||||
|
|
||||||
|
import Phoenix.LiveViewTest
|
||||||
|
|
||||||
|
alias Mv.Helpers.SystemActor
|
||||||
|
|
||||||
|
setup %{conn: conn} do
|
||||||
|
{:ok, _} =
|
||||||
|
Mv.Membership.create_member(
|
||||||
|
%{
|
||||||
|
first_name: "Ada",
|
||||||
|
last_name: "Lovelace",
|
||||||
|
email: "ada@example.com",
|
||||||
|
street: "Baker Street",
|
||||||
|
house_number: "221",
|
||||||
|
postal_code: "10115",
|
||||||
|
city: "Berlin"
|
||||||
|
},
|
||||||
|
actor: SystemActor.get_system_actor()
|
||||||
|
)
|
||||||
|
|
||||||
|
%{conn: conn_with_oidc_user(conn)}
|
||||||
|
end
|
||||||
|
|
||||||
|
defp open_view_settings(view) do
|
||||||
|
view |> element("[data-testid='view-settings-button']") |> render_click()
|
||||||
|
view
|
||||||
|
end
|
||||||
|
|
||||||
|
test "member cell hides the email line by default", %{conn: conn} do
|
||||||
|
{:ok, view, _html} = live(conn, ~p"/members")
|
||||||
|
|
||||||
|
assert has_element?(view, "[data-testid='member-name']")
|
||||||
|
refute has_element?(view, "[data-testid='member-name-email']")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "the include-email sub-toggle surfaces the email line in the member cell", %{conn: conn} do
|
||||||
|
{:ok, view, _html} = live(conn, ~p"/members")
|
||||||
|
|
||||||
|
open_view_settings(view)
|
||||||
|
view |> element("[data-testid='view-setting-member-include-email']") |> render_click()
|
||||||
|
|
||||||
|
assert has_element?(view, "[data-testid='member-name-email']", "ada@example.com")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "turning off the compact member field shows separate name columns", %{conn: conn} do
|
||||||
|
{:ok, view, _html} = live(conn, ~p"/members")
|
||||||
|
|
||||||
|
open_view_settings(view)
|
||||||
|
view |> element("[data-testid='view-setting-compact-member']") |> render_click()
|
||||||
|
|
||||||
|
refute has_element?(view, "[data-testid='member-name']")
|
||||||
|
assert has_element?(view, "[data-testid='first_name']")
|
||||||
|
assert has_element?(view, "[data-testid='last_name']")
|
||||||
|
# With the composite off, the email is surfaced as its own column.
|
||||||
|
assert has_element?(view, "[data-testid='email']")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "with the compact member field off, the include-email sub-toggle is hidden",
|
||||||
|
%{conn: conn} do
|
||||||
|
{:ok, view, _html} = live(conn, ~p"/members")
|
||||||
|
|
||||||
|
open_view_settings(view)
|
||||||
|
# While the composite Member field is on, the sub-toggle is available.
|
||||||
|
assert has_element?(view, "[data-testid='view-setting-member-include-email']")
|
||||||
|
|
||||||
|
view |> element("[data-testid='view-setting-compact-member']") |> render_click()
|
||||||
|
|
||||||
|
# Once the composite is off, the email is a separate column, so the
|
||||||
|
# in-cell "include email" sub-toggle no longer applies and is hidden.
|
||||||
|
refute has_element?(view, "[data-testid='view-setting-member-include-email']")
|
||||||
|
assert has_element?(view, "[data-testid='email']")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "the compact address field renders a composite address cell by default", %{conn: conn} do
|
||||||
|
{:ok, view, _html} = live(conn, ~p"/members")
|
||||||
|
|
||||||
|
assert has_element?(view, "[data-testid='member-address']")
|
||||||
|
refute has_element?(view, "[data-testid='street']")
|
||||||
|
refute has_element?(view, "[data-testid='postal_code']")
|
||||||
|
refute has_element?(view, "[data-testid='city']")
|
||||||
|
end
|
||||||
|
|
||||||
|
test "turning off the compact address field shows separate address columns", %{conn: conn} do
|
||||||
|
{:ok, view, _html} = live(conn, ~p"/members")
|
||||||
|
|
||||||
|
open_view_settings(view)
|
||||||
|
view |> element("[data-testid='view-setting-compact-address']") |> render_click()
|
||||||
|
|
||||||
|
refute has_element?(view, "[data-testid='member-address']")
|
||||||
|
assert has_element?(view, "[data-testid='street']")
|
||||||
|
assert has_element?(view, "[data-testid='postal_code']")
|
||||||
|
assert has_element?(view, "[data-testid='city']")
|
||||||
|
end
|
||||||
|
end
|
||||||
Loading…
Add table
Add a link
Reference in a new issue