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, {
|
||||
longPollFallbackMs: 2500,
|
||||
params: {
|
||||
_csrf_token: csrfToken,
|
||||
timezone: getBrowserTimezone()
|
||||
timezone: getBrowserTimezone(),
|
||||
view_settings: getCookie("member_view_settings")
|
||||
},
|
||||
hooks: Hooks
|
||||
})
|
||||
|
||||
// Persist the member-overview view settings per browser/device (§1.5/§1.20). The
|
||||
// LiveView pushes "store-view-settings" (density + compact field toggles) on
|
||||
// change; we write a long-lived cookie that the LiveView reads back on the next
|
||||
// full page load (via the request cookie on the dead render and via connect
|
||||
// params on the connected mount).
|
||||
// Return the members list to the top after a sort or filter change. The server
|
||||
// resets the keyset stream to page 1 on those changes and pushes this event; a
|
||||
// user scrolled down would otherwise be stranded past the shorter content with
|
||||
// infinite scroll not re-arming. No-op if the scroll container is absent.
|
||||
window.addEventListener("phx:members:scroll-top", () => {
|
||||
const el = document.getElementById("members-table-guard")
|
||||
if (el) el.scrollTop = 0
|
||||
})
|
||||
|
||||
window.addEventListener("phx:store-view-settings", (e) => {
|
||||
const json = e.detail && e.detail.view_settings
|
||||
if (typeof json === "string" && json.length > 0) {
|
||||
const maxAge = 365 * 24 * 60 * 60
|
||||
document.cookie = `member_view_settings=${encodeURIComponent(json)};path=/;max-age=${maxAge};samesite=lax`
|
||||
}
|
||||
})
|
||||
|
||||
// Listen for custom events from LiveView
|
||||
window.addEventListener("phx:set-input-value", (e) => {
|
||||
const {id, value} = e.detail
|
||||
|
|
@ -432,15 +463,6 @@ window.addEventListener("phx:set-input-value", (e) => {
|
|||
}
|
||||
})
|
||||
|
||||
// Return the members list to the top after a sort or filter change. The server
|
||||
// resets the keyset stream to page 1 on those changes and pushes this event; a
|
||||
// user scrolled down would otherwise be stranded past the shorter content with
|
||||
// infinite scroll not re-arming. No-op if the scroll container is absent.
|
||||
window.addEventListener("phx:members:scroll-top", () => {
|
||||
const el = document.getElementById("members-table-guard")
|
||||
if (el) el.scrollTop = 0
|
||||
})
|
||||
|
||||
// Show progress bar on live navigation and form submits
|
||||
topbar.config({barColors: {0: "#29d"}, shadowColor: "rgba(0, 0, 0, .3)"})
|
||||
window.addEventListener("phx:page-loading-start", _info => topbar.show(300))
|
||||
|
|
|
|||
|
|
@ -250,7 +250,7 @@ defmodule MvWeb.CoreComponents do
|
|||
|
||||
attr :size, :any,
|
||||
default: "md",
|
||||
doc: "Badge size: sm | md"
|
||||
doc: "Badge size: xs | sm | md"
|
||||
|
||||
attr :sr_label, :string,
|
||||
default: nil,
|
||||
|
|
@ -269,7 +269,7 @@ defmodule MvWeb.CoreComponents do
|
|||
|
||||
variant_class = "badge-#{variant}"
|
||||
style_class = badge_style_class(style)
|
||||
size_class = "badge-#{size}"
|
||||
size_class = badge_size_class(size)
|
||||
# Outline has transparent bg in DaisyUI; add bg so it stays visible on base-200/base-300
|
||||
outline_bg = if style == "outline", do: "bg-base-100", else: nil
|
||||
|
||||
|
|
@ -309,6 +309,13 @@ defmodule MvWeb.CoreComponents do
|
|||
defp badge_style_class("outline"), do: "badge-outline"
|
||||
defp badge_style_class(_), do: nil
|
||||
|
||||
# Literal strings so Tailwind's content scanner sees badge-xs / badge-sm / badge-md / badge-lg
|
||||
# and does not purge them from the CSS bundle (string interpolation "badge-#{size}" would be invisible).
|
||||
defp badge_size_class("xs"), do: "badge-xs"
|
||||
defp badge_size_class("sm"), do: "badge-sm"
|
||||
defp badge_size_class("lg"), do: "badge-lg"
|
||||
defp badge_size_class(_), do: "badge-md"
|
||||
|
||||
@doc """
|
||||
Renders a visually empty table cell with screen-reader-only text (WCAG).
|
||||
|
||||
|
|
@ -443,6 +450,11 @@ defmodule MvWeb.CoreComponents do
|
|||
attr :selected, :map, default: %{}
|
||||
attr :open, :boolean, default: false, doc: "Whether the dropdown is open"
|
||||
attr :show_select_buttons, :boolean, default: false, doc: "Show select all/none buttons"
|
||||
|
||||
attr :show_reset_button, :boolean,
|
||||
default: false,
|
||||
doc: "Show a reset-to-default icon button next to All/None (emits reset_fields)"
|
||||
|
||||
attr :phx_target, :any, required: true, doc: "The LiveView/LiveComponent target for events"
|
||||
attr :menu_class, :string, default: nil, doc: "Additional CSS classes for the menu"
|
||||
attr :menu_width, :string, default: "w-64", doc: "Width class for the menu (default: w-64)"
|
||||
|
|
@ -546,6 +558,19 @@ defmodule MvWeb.CoreComponents do
|
|||
>
|
||||
{gettext("None")}
|
||||
</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>
|
||||
</li>
|
||||
|
|
@ -938,6 +963,11 @@ defmodule MvWeb.CoreComponents do
|
|||
attr :sort_field, :any, default: nil, doc: "current sort field"
|
||||
attr :sort_order, :atom, default: nil, doc: "current sort order"
|
||||
|
||||
attr :size_class, :string,
|
||||
default: "",
|
||||
doc:
|
||||
"optional DaisyUI table-size class controlling row density (e.g. table-xs, table-md); driven by the view-settings density value"
|
||||
|
||||
attr :sticky_header, :boolean,
|
||||
default: false,
|
||||
doc:
|
||||
|
|
@ -1016,7 +1046,7 @@ defmodule MvWeb.CoreComponents do
|
|||
data-sticky-first-col-rows={@sticky_first_col && "true"}
|
||||
phx-hook={@row_click && "TableRowKeydown"}
|
||||
>
|
||||
<table class="table table-zebra">
|
||||
<table class={["table table-zebra", @size_class]}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th
|
||||
|
|
@ -1223,7 +1253,7 @@ defmodule MvWeb.CoreComponents do
|
|||
defp table_th_sticky_class(_), do: nil
|
||||
|
||||
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 """
|
||||
Renders a reorderable table (sortable list) with drag handle and keyboard support.
|
||||
|
|
|
|||
|
|
@ -69,12 +69,13 @@ defmodule MvWeb.Components.FieldVisibilityDropdownComponent do
|
|||
<.dropdown_menu
|
||||
id="field-visibility-menu"
|
||||
icon="hero-adjustments-horizontal"
|
||||
button_label={gettext("Show/Hide Columns")}
|
||||
button_label={gettext("Columns")}
|
||||
items={@all_items}
|
||||
checkboxes={true}
|
||||
selected={@selected_fields}
|
||||
open={@open}
|
||||
show_select_buttons={true}
|
||||
show_reset_button={true}
|
||||
phx_target={@myself}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -115,6 +116,13 @@ defmodule MvWeb.Components.FieldVisibilityDropdownComponent do
|
|||
{:noreply, assign(socket, :selected_fields, all)}
|
||||
end
|
||||
|
||||
# reset to the curated default column set (the parent owns the settings needed
|
||||
# to compute the default, so it recomputes and re-applies the selection)
|
||||
def handle_event("reset_fields", _params, socket) do
|
||||
send(self(), {:fields_reset})
|
||||
{:noreply, socket}
|
||||
end
|
||||
|
||||
# select none
|
||||
def handle_event("select_none", _params, socket) do
|
||||
none =
|
||||
|
|
|
|||
|
|
@ -32,11 +32,14 @@ defmodule MvWeb.Components.SortHeaderComponent do
|
|||
>
|
||||
{@label}
|
||||
<%= if @sort_field == @field do %>
|
||||
<.icon name={if @sort_order == :asc, do: "hero-chevron-up", else: "hero-chevron-down"} />
|
||||
<.icon
|
||||
name={if @sort_order == :asc, do: "hero-chevron-up", else: "hero-chevron-down"}
|
||||
class="sort-icon"
|
||||
/>
|
||||
<% else %>
|
||||
<.icon
|
||||
name="hero-chevron-up-down"
|
||||
class="opacity-40"
|
||||
class="sort-icon opacity-40"
|
||||
/>
|
||||
<% end %>
|
||||
</.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.MembershipFeeStatus
|
||||
alias MvWeb.MemberLive.Index.OverviewQuery
|
||||
alias MvWeb.MemberLive.Index.ViewSettings
|
||||
|
||||
require Ash.Query
|
||||
require Logger
|
||||
|
|
@ -123,16 +124,31 @@ defmodule MvWeb.MemberLive.Index do
|
|||
# Load user field selection from 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_custom_fields
|
||||
|> FieldVisibility.get_all_available_fields()
|
||||
FieldVisibility.get_offered_fields(
|
||||
all_custom_fields,
|
||||
compact_member,
|
||||
member_include_email,
|
||||
compact_address
|
||||
)
|
||||
|
||||
initial_selection =
|
||||
FieldVisibility.merge_with_global_settings(
|
||||
session_selection,
|
||||
settings,
|
||||
all_custom_fields
|
||||
all_custom_fields,
|
||||
compact_member: compact_member,
|
||||
member_include_email: member_include_email,
|
||||
compact_address: compact_address
|
||||
)
|
||||
|
||||
socket =
|
||||
|
|
@ -183,6 +199,7 @@ defmodule MvWeb.MemberLive.Index do
|
|||
|> assign(:mailto_bcc, "")
|
||||
|> assign(:recipient_count, 0)
|
||||
|> assign(:mailto_disabled?, false)
|
||||
|> assign_view_settings(view_settings)
|
||||
|> stream_configure(:members, dom_id: &"row-#{&1.id}")
|
||||
|> stream(:members, [])
|
||||
|> assign_export_payload()
|
||||
|
|
@ -385,6 +402,11 @@ defmodule MvWeb.MemberLive.Index do
|
|||
- `{: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
|
||||
def handle_info({:search_changed, q}, socket) do
|
||||
socket =
|
||||
|
|
@ -604,34 +626,10 @@ defmodule MvWeb.MemberLive.Index do
|
|||
@impl true
|
||||
def handle_info({:field_toggled, field_string, visible}, socket) do
|
||||
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
|
||||
|> assign(:user_field_selection, 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_field_visibility(new_selection)
|
||||
|> load_members()
|
||||
|> prepare_dynamic_cols()
|
||||
|> update_selection_assigns()
|
||||
|
|
@ -641,35 +639,29 @@ defmodule MvWeb.MemberLive.Index do
|
|||
end
|
||||
|
||||
@impl true
|
||||
def handle_info({:fields_selected, selection}, socket) do
|
||||
socket = update_session_field_selection(socket, selection)
|
||||
def handle_info({:fields_reset}, socket) do
|
||||
# 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(
|
||||
selection,
|
||||
%{},
|
||||
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 =
|
||||
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)
|
||||
handle_info({:fields_selected, default_selection}, socket)
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info({:fields_selected, selection}, socket) do
|
||||
socket =
|
||||
socket
|
||||
|> assign(:user_field_selection, 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_field_visibility(selection)
|
||||
|> load_members()
|
||||
|> prepare_dynamic_cols()
|
||||
|> update_selection_assigns()
|
||||
|
|
@ -697,18 +689,6 @@ defmodule MvWeb.MemberLive.Index do
|
|||
url_selection = FieldSelection.parse_from_url(params)
|
||||
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
|
||||
|> maybe_update_search(params)
|
||||
|
|
@ -721,11 +701,7 @@ defmodule MvWeb.MemberLive.Index do
|
|||
|> maybe_update_show_current_cycle(params)
|
||||
|> assign(:fields_in_url?, fields_in_url?)
|
||||
|> assign(:query, params["query"])
|
||||
|> assign(:user_field_selection, 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_visibility_derivations(final_selection)
|
||||
|> assign(:selected_member_id, parse_highlight_param(params["highlight"]))
|
||||
|
||||
next_sig = build_signature(socket)
|
||||
|
|
@ -863,8 +839,52 @@ defmodule MvWeb.MemberLive.Index do
|
|||
push_reload(socket, new_path)
|
||||
end
|
||||
|
||||
defp update_session_field_selection(socket, selection) do
|
||||
assign(socket, :user_field_selection, selection)
|
||||
# The current composite view flags; the offered column set is scoped to these.
|
||||
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
|
||||
|
||||
defp build_query_params(opts) when is_map(opts) do
|
||||
|
|
@ -904,8 +924,14 @@ defmodule MvWeb.MemberLive.Index do
|
|||
end
|
||||
|
||||
defp compute_final_field_selection(true, url_selection, socket) do
|
||||
{cm, ie, ca} = view_flags(socket)
|
||||
|
||||
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_custom = FieldVisibility.get_visible_custom_fields(only_url)
|
||||
|
|
@ -919,6 +945,8 @@ defmodule MvWeb.MemberLive.Index do
|
|||
end
|
||||
|
||||
defp compute_final_field_selection(false, url_selection, socket) do
|
||||
{cm, ie, ca} = view_flags(socket)
|
||||
|
||||
merged =
|
||||
FieldSelection.merge_sources(
|
||||
url_selection,
|
||||
|
|
@ -929,7 +957,10 @@ defmodule MvWeb.MemberLive.Index do
|
|||
FieldVisibility.merge_with_global_settings(
|
||||
merged,
|
||||
socket.assigns.settings,
|
||||
socket.assigns.all_custom_fields
|
||||
socket.assigns.all_custom_fields,
|
||||
compact_member: cm,
|
||||
member_include_email: ie,
|
||||
compact_address: ca
|
||||
)
|
||||
end
|
||||
|
||||
|
|
@ -941,6 +972,181 @@ defmodule MvWeb.MemberLive.Index do
|
|||
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.
|
||||
defp parse_highlight_param(nil), do: nil
|
||||
defp parse_highlight_param(""), do: nil
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@
|
|||
<.live_component
|
||||
module={MvWeb.Components.BulkActionsDropdown}
|
||||
id="bulk-actions-dropdown"
|
||||
open={@bulk_actions_open}
|
||||
export_payload_json={@export_payload_json}
|
||||
selected_count={@selected_count}
|
||||
scope={@scope}
|
||||
|
|
@ -72,13 +71,23 @@
|
|||
</span>
|
||||
</.button>
|
||||
</.tooltip>
|
||||
<.live_component
|
||||
module={MvWeb.Components.FieldVisibilityDropdownComponent}
|
||||
id="field-visibility-dropdown"
|
||||
all_fields={@all_available_fields}
|
||||
custom_fields={@all_custom_fields}
|
||||
selected_fields={@user_field_selection}
|
||||
/>
|
||||
<div class="ml-auto flex items-center gap-4">
|
||||
<.live_component
|
||||
module={MvWeb.Components.FieldVisibilityDropdownComponent}
|
||||
id="field-visibility-dropdown"
|
||||
all_fields={@all_available_fields}
|
||||
custom_fields={@all_custom_fields}
|
||||
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>
|
||||
|
||||
<%!-- Polite live region: present on first render (before it is filled) so
|
||||
|
|
@ -103,6 +112,8 @@
|
|||
role="region"
|
||||
aria-label={gettext("Members table")}
|
||||
aria-busy={to_string(@loading?)}
|
||||
data-density={@density}
|
||||
tabindex="0"
|
||||
>
|
||||
<.table
|
||||
id="members"
|
||||
|
|
@ -122,6 +133,7 @@
|
|||
dynamic_cols={@dynamic_cols}
|
||||
sort_field={@sort_field}
|
||||
sort_order={@sort_order}
|
||||
size_class={if @density == :compact, do: "table-xs", else: "table-md"}
|
||||
>
|
||||
|
||||
<!-- <:col :let={member} label="Id">{member.id}</:col> -->
|
||||
|
|
@ -130,10 +142,14 @@
|
|||
col_click={&MvWeb.MemberLive.Index.checkbox_column_click/1}
|
||||
label={
|
||||
~H"""
|
||||
<.input
|
||||
<input
|
||||
type="checkbox"
|
||||
id="select-all-checkbox"
|
||||
name="select_all"
|
||||
class="checkbox checkbox-sm"
|
||||
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}
|
||||
aria-label={gettext("Select all members")}
|
||||
role="checkbox"
|
||||
|
|
@ -141,9 +157,10 @@
|
|||
"""
|
||||
}
|
||||
>
|
||||
<.input
|
||||
<input
|
||||
type="checkbox"
|
||||
name={member.id}
|
||||
class="checkbox checkbox-sm"
|
||||
checked={MapSet.member?(@selected_members, member.id)}
|
||||
aria-label={gettext("Select member")}
|
||||
role="checkbox"
|
||||
|
|
@ -151,8 +168,21 @@
|
|||
</:col>
|
||||
<:col
|
||||
:let={member}
|
||||
:if={:name in @member_fields_visible}
|
||||
label={gettext("Name")}
|
||||
: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")}
|
||||
sort_field={@sort_field}
|
||||
sort_order={@sort_order}
|
||||
sort_hint={gettext("Click to sort by last name")}
|
||||
/>
|
||||
"""
|
||||
}
|
||||
>
|
||||
<% full_name =
|
||||
[member.first_name, member.last_name]
|
||||
|
|
@ -163,7 +193,8 @@
|
|||
{full_name}
|
||||
</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"
|
||||
title={member.email}
|
||||
>
|
||||
|
|
@ -171,29 +202,10 @@
|
|||
</div>
|
||||
</div>
|
||||
</: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
|
||||
:let={member}
|
||||
:if={:first_name in @member_fields_visible}
|
||||
sort_field={:first_name}
|
||||
label={
|
||||
~H"""
|
||||
<.live_component
|
||||
|
|
@ -212,6 +224,7 @@
|
|||
<:col
|
||||
:let={member}
|
||||
:if={:last_name in @member_fields_visible}
|
||||
sort_field={:last_name}
|
||||
label={
|
||||
~H"""
|
||||
<.live_component
|
||||
|
|
@ -230,6 +243,7 @@
|
|||
<:col
|
||||
:let={member}
|
||||
:if={:email in @member_fields_visible}
|
||||
sort_field={:email}
|
||||
label={
|
||||
~H"""
|
||||
<.live_component
|
||||
|
|
@ -245,9 +259,117 @@
|
|||
>
|
||||
{member.email}
|
||||
</: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
|
||||
:let={member}
|
||||
:if={:join_date in @member_fields_visible}
|
||||
sort_field={:join_date}
|
||||
label={
|
||||
~H"""
|
||||
<.live_component
|
||||
|
|
@ -266,6 +388,7 @@
|
|||
<:col
|
||||
:let={member}
|
||||
:if={:exit_date in @member_fields_visible}
|
||||
sort_field={:exit_date}
|
||||
label={
|
||||
~H"""
|
||||
<.live_component
|
||||
|
|
@ -291,6 +414,7 @@
|
|||
<:col
|
||||
:let={member}
|
||||
:if={:country in @member_fields_visible}
|
||||
sort_field={:country}
|
||||
label={
|
||||
~H"""
|
||||
<.live_component
|
||||
|
|
@ -306,89 +430,10 @@
|
|||
>
|
||||
{member.country}
|
||||
</: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
|
||||
:let={member}
|
||||
:if={:membership_fee_start_date in @member_fields_visible}
|
||||
sort_field={:membership_fee_start_date}
|
||||
label={
|
||||
~H"""
|
||||
<.live_component
|
||||
|
|
@ -407,6 +452,7 @@
|
|||
<:col
|
||||
:let={member}
|
||||
:if={:membership_fee_type in @member_fields_visible}
|
||||
sort_field={:membership_fee_type}
|
||||
label={
|
||||
~H"""
|
||||
<.live_component
|
||||
|
|
@ -443,6 +489,7 @@
|
|||
<:col
|
||||
:let={member}
|
||||
:if={:groups in @member_fields_visible}
|
||||
sort_field={:groups}
|
||||
label={
|
||||
~H"""
|
||||
<.live_component
|
||||
|
|
@ -491,11 +538,17 @@
|
|||
class="h-0"
|
||||
>
|
||||
</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
|
||||
id="members-loading-bar"
|
||||
phx-hook="StickyViewportWidth"
|
||||
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"
|
||||
>
|
||||
<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).
|
||||
# :name and :address are composite display-only columns (Name = name + email,
|
||||
# Address = street/house number over postal code/city).
|
||||
@pseudo_member_fields [:membership_fee_status, :membership_fee_type, :groups, :name, :address]
|
||||
# Order mirrors the overview table / export column order (fee type before fee
|
||||
# status), so the Columns dropdown and the table agree.
|
||||
@pseudo_member_fields [:membership_fee_type, :membership_fee_status, :groups, :name, :address]
|
||||
|
||||
# The composite display columns and the constituent columns they replace. The
|
||||
# column manager offers exactly one variant per group, selected by the view
|
||||
# settings: the composite when the matching "compact" setting is on, otherwise
|
||||
# the constituents (see `offered_member_fields/2`).
|
||||
@name_composite :name
|
||||
@name_constituents [:first_name, :last_name, :email]
|
||||
@address_composite :address
|
||||
@address_constituents [:street, :house_number, :postal_code, :city]
|
||||
|
||||
# Export/API may accept this as alias; must not appear in the UI options list.
|
||||
@export_only_alias :payment_status
|
||||
|
||||
# Curated default-visible column set (§1.2): the columns shown on first visit
|
||||
# when there is no persisted selection and no global override. Everything else
|
||||
|
|
@ -52,8 +66,139 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do
|
|||
@spec default_visible_fields() :: [atom()]
|
||||
def default_visible_fields, do: MapSet.to_list(@default_visible_fields)
|
||||
|
||||
# Export/API may accept this as alias; must not appear in the UI options list.
|
||||
@export_only_alias :payment_status
|
||||
@doc """
|
||||
The curated default-visible columns for the given view settings.
|
||||
|
||||
In compact mode the composite `:name` / `:address` columns are visible by
|
||||
default; when a composite is switched off, its constituent columns take over
|
||||
as the defaults instead (§7b).
|
||||
|
||||
While the Member composite is compact, `include_email` decides where the email
|
||||
lives: folded into the Member cell (`true`, no separate column), or as a
|
||||
default-visible `:email` column (`false`).
|
||||
"""
|
||||
@spec default_visible_fields(boolean(), boolean(), boolean()) :: [atom()]
|
||||
def default_visible_fields(compact_member, include_email, compact_address) do
|
||||
compact_member
|
||||
|> default_visible_set(include_email, compact_address)
|
||||
|> MapSet.to_list()
|
||||
end
|
||||
|
||||
defp default_visible_set(compact_member, include_email, compact_address) do
|
||||
@default_visible_fields
|
||||
|> apply_name_default(compact_member, include_email)
|
||||
|> apply_group_default(compact_address, @address_composite, @address_constituents)
|
||||
end
|
||||
|
||||
# Compact Member cell with email folded in: only the composite is default.
|
||||
defp apply_name_default(set, true = _compact, true = _include_email), do: set
|
||||
|
||||
# Compact Member cell without folded email: the composite plus a separate
|
||||
# default-visible E-Mail column.
|
||||
defp apply_name_default(set, true = _compact, false = _include_email),
|
||||
do: MapSet.put(set, :email)
|
||||
|
||||
# Non-compact: drop the composite and make first/last name and email default.
|
||||
defp apply_name_default(set, false = _compact, _include_email) do
|
||||
set
|
||||
|> MapSet.delete(@name_composite)
|
||||
|> MapSet.union(MapSet.new(@name_constituents))
|
||||
end
|
||||
|
||||
# Compact: the composite stays the default. Non-compact: drop the composite and
|
||||
# make the constituents default-visible.
|
||||
defp apply_group_default(set, true, _composite, _constituents), do: set
|
||||
|
||||
defp apply_group_default(set, false, composite, constituents) do
|
||||
set
|
||||
|> MapSet.delete(composite)
|
||||
|> MapSet.union(MapSet.new(constituents))
|
||||
end
|
||||
|
||||
@doc """
|
||||
Member-field atoms the column manager offers for the given view settings, in
|
||||
dropdown/table order.
|
||||
|
||||
The identity block always leads, regardless of whether each group is compact:
|
||||
|
||||
1. the name group — the composite `:name` when compact, otherwise its
|
||||
constituents `:first_name`, `:last_name`;
|
||||
2. the `:email` column, when offered as a separate column (it is folded into
|
||||
the Member cell only while the composite is compact and `include_email` is
|
||||
on);
|
||||
3. the address group — the composite `:address` when compact, otherwise its
|
||||
constituents `:street`, `:house_number`, `:postal_code`, `:city`.
|
||||
|
||||
Everything else follows in the configured Datenfelder order. This keeps the
|
||||
name group ahead of the address group even when the name group is expanded
|
||||
into `:first_name`/`:last_name` while the address stays compact.
|
||||
"""
|
||||
@spec offered_member_fields(boolean(), boolean(), boolean()) :: [atom()]
|
||||
def offered_member_fields(compact_member, include_email, compact_address) do
|
||||
name_group = if compact_member, do: [@name_composite], else: [:first_name, :last_name]
|
||||
email_group = if email_offered?(compact_member, include_email), do: [:email], else: []
|
||||
|
||||
address_group =
|
||||
if compact_address, do: [@address_composite], else: @address_constituents
|
||||
|
||||
leading = name_group ++ email_group ++ address_group
|
||||
|
||||
rest =
|
||||
Enum.reject(overview_member_fields(), fn field ->
|
||||
field == @export_only_alias or
|
||||
hidden_variant?(field, compact_member, include_email, compact_address) or
|
||||
field in leading
|
||||
end)
|
||||
|
||||
leading ++ rest
|
||||
end
|
||||
|
||||
# The email is a separate offered column except when it is folded into the
|
||||
# compact Member cell (compact + include_email).
|
||||
defp email_offered?(compact_member, include_email), do: not (compact_member and include_email)
|
||||
|
||||
# The variant of a composite group that is not offered in the current mode.
|
||||
defp hidden_variant?(field, compact_member, include_email, compact_address) do
|
||||
name_variant_hidden?(field, compact_member, include_email) or
|
||||
address_variant_hidden?(field, compact_address)
|
||||
end
|
||||
|
||||
# Compact hides Vorname/Nachname (folded into the composite). The email is
|
||||
# hidden only when it is folded into the cell (compact + include_email); with
|
||||
# include_email off it stays offered as a separate column. Non-compact hides the
|
||||
# composite in favour of the constituents.
|
||||
defp name_variant_hidden?(@name_composite, compact_member, _include_email),
|
||||
do: not compact_member
|
||||
|
||||
defp name_variant_hidden?(:email, compact_member, include_email),
|
||||
do: compact_member and include_email
|
||||
|
||||
defp name_variant_hidden?(field, compact_member, _include_email)
|
||||
when field in [:first_name, :last_name],
|
||||
do: compact_member
|
||||
|
||||
defp name_variant_hidden?(_field, _compact_member, _include_email), do: false
|
||||
|
||||
defp address_variant_hidden?(@address_composite, compact_address),
|
||||
do: not compact_address
|
||||
|
||||
defp address_variant_hidden?(field, compact_address)
|
||||
when field in [:street, :house_number, :postal_code, :city],
|
||||
do: compact_address
|
||||
|
||||
defp address_variant_hidden?(_field, _compact_address), do: false
|
||||
|
||||
@doc """
|
||||
All fields the column manager offers for the given view settings: the offered
|
||||
member fields (see `offered_member_fields/3`) followed by the custom fields.
|
||||
"""
|
||||
@spec get_offered_fields([struct()], boolean(), boolean(), boolean()) :: [
|
||||
atom() | String.t()
|
||||
]
|
||||
def get_offered_fields(custom_fields, compact_member, include_email, compact_address) do
|
||||
custom_field_names = Enum.map(custom_fields, &"custom_field_#{&1.id}")
|
||||
offered_member_fields(compact_member, include_email, compact_address) ++ custom_field_names
|
||||
end
|
||||
|
||||
defp overview_member_fields do
|
||||
Mv.Constants.member_fields() ++ @pseudo_member_fields
|
||||
|
|
@ -93,8 +238,28 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do
|
|||
String.t() => boolean()
|
||||
}
|
||||
def selection_from_url_only(url_selection, custom_fields) when is_map(url_selection) do
|
||||
all_fields = get_all_available_fields(custom_fields)
|
||||
do_selection_from_url_only(url_selection, get_all_available_fields(custom_fields))
|
||||
end
|
||||
|
||||
def selection_from_url_only(_, _), do: %{}
|
||||
|
||||
@doc """
|
||||
Like `selection_from_url_only/2`, but scoped to the columns offered for the
|
||||
given view settings (`:compact_member` / `:member_include_email` /
|
||||
`:compact_address` in `opts`).
|
||||
"""
|
||||
@spec selection_from_url_only(%{String.t() => boolean()}, [struct()], keyword()) :: %{
|
||||
String.t() => boolean()
|
||||
}
|
||||
def selection_from_url_only(url_selection, custom_fields, opts)
|
||||
when is_map(url_selection) and is_list(opts) do
|
||||
{cm, ie, ca} = view_opts(opts)
|
||||
do_selection_from_url_only(url_selection, get_offered_fields(custom_fields, cm, ie, ca))
|
||||
end
|
||||
|
||||
def selection_from_url_only(_, _, _), do: %{}
|
||||
|
||||
defp do_selection_from_url_only(url_selection, all_fields) do
|
||||
Enum.reduce(all_fields, %{}, fn field, acc ->
|
||||
field_string = field_to_string(field)
|
||||
visible = Map.get(url_selection, field_string, false)
|
||||
|
|
@ -102,7 +267,13 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do
|
|||
end)
|
||||
end
|
||||
|
||||
def selection_from_url_only(_, _), do: %{}
|
||||
defp view_opts(opts) do
|
||||
{
|
||||
Keyword.get(opts, :compact_member, true),
|
||||
Keyword.get(opts, :member_include_email, false),
|
||||
Keyword.get(opts, :compact_address, true)
|
||||
}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Merges user field selection with global settings.
|
||||
|
|
@ -135,7 +306,31 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do
|
|||
) :: %{String.t() => boolean()}
|
||||
def merge_with_global_settings(user_selection, global_settings, custom_fields) do
|
||||
all_fields = get_all_available_fields(custom_fields)
|
||||
global_visibility = get_global_visibility_map(global_settings, custom_fields)
|
||||
do_merge(user_selection, global_settings, custom_fields, all_fields, @default_visible_fields)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Like `merge_with_global_settings/3`, but scoped to the columns offered for the
|
||||
given view settings (`:compact_member` / `:member_include_email` /
|
||||
`:compact_address` in `opts`).
|
||||
|
||||
Only offered columns appear in the result, so a column that is not applicable
|
||||
in the current mode (e.g. `:first_name` while the composite "Name" is on) never
|
||||
leaks into the visible set. Defaults follow the mode via
|
||||
`default_visible_fields/2`.
|
||||
"""
|
||||
@spec merge_with_global_settings(%{String.t() => boolean()}, map(), [struct()], keyword()) ::
|
||||
%{String.t() => boolean()}
|
||||
def merge_with_global_settings(user_selection, global_settings, custom_fields, opts)
|
||||
when is_list(opts) do
|
||||
{cm, ie, ca} = view_opts(opts)
|
||||
all_fields = get_offered_fields(custom_fields, cm, ie, ca)
|
||||
default_set = default_visible_set(cm, ie, ca)
|
||||
do_merge(user_selection, global_settings, custom_fields, all_fields, default_set)
|
||||
end
|
||||
|
||||
defp do_merge(user_selection, global_settings, custom_fields, all_fields, default_set) do
|
||||
global_visibility = get_global_visibility_map(global_settings, custom_fields, default_set)
|
||||
|
||||
Enum.reduce(all_fields, %{}, fn field, acc ->
|
||||
field_string = field_to_string(field)
|
||||
|
|
@ -280,15 +475,15 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do
|
|||
def get_visible_custom_fields(_), do: []
|
||||
|
||||
# Gets global visibility map from settings
|
||||
defp get_global_visibility_map(settings, custom_fields) do
|
||||
member_visibility = get_member_field_visibility_from_settings(settings)
|
||||
defp get_global_visibility_map(settings, custom_fields, default_set) do
|
||||
member_visibility = get_member_field_visibility_from_settings(settings, default_set)
|
||||
custom_field_visibility = get_custom_field_visibility(custom_fields)
|
||||
|
||||
Map.merge(member_visibility, custom_field_visibility)
|
||||
end
|
||||
|
||||
# Gets member field visibility from settings (domain fields from settings, pseudo fields default true)
|
||||
defp get_member_field_visibility_from_settings(settings) do
|
||||
defp get_member_field_visibility_from_settings(settings, default_set) do
|
||||
visibility_config =
|
||||
VisibilityConfig.normalize(Map.get(settings, :member_field_visibility, %{}))
|
||||
|
||||
|
|
@ -297,13 +492,13 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do
|
|||
domain_map =
|
||||
Enum.reduce(domain_fields, %{}, fn field, acc ->
|
||||
field_string = Atom.to_string(field)
|
||||
default_visibility = MapSet.member?(@default_visible_fields, field)
|
||||
default_visibility = MapSet.member?(default_set, field)
|
||||
show_in_overview = Map.get(visibility_config, field, default_visibility)
|
||||
Map.put(acc, field_string, show_in_overview)
|
||||
end)
|
||||
|
||||
Enum.reduce(@pseudo_member_fields, domain_map, fn field, acc ->
|
||||
Map.put(acc, Atom.to_string(field), MapSet.member?(@default_visible_fields, field))
|
||||
Map.put(acc, Atom.to_string(field), MapSet.member?(default_set, field))
|
||||
end)
|
||||
end
|
||||
|
||||
|
|
|
|||
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,
|
||||
# so these component tests make every sortable field visible via ?fields=.
|
||||
# The individual name/address constituents are only *offered* when the compact
|
||||
# Member / Address view settings are off, so the session opts them out of the
|
||||
# composite columns before selecting the fields via ?fields=.
|
||||
@cols "first_name,email,street,house_number,postal_code,city,country,join_date"
|
||||
|
||||
# Disables the compact Member/Address composites so first_name/last_name/email
|
||||
# and street/house_number/postal_code/city are offered as individual sortable
|
||||
# columns (persisted per browser via the view-settings session key).
|
||||
defp non_compact(conn) do
|
||||
Plug.Conn.put_session(
|
||||
conn,
|
||||
"member_view_settings",
|
||||
~s({"compact_member":false,"compact_address":false})
|
||||
)
|
||||
end
|
||||
|
||||
describe "rendering" do
|
||||
test "renders with correct attributes", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
conn = conn |> conn_with_oidc_user() |> non_compact()
|
||||
{:ok, view, _html} = live(conn, "/members?fields=#{@cols}")
|
||||
|
||||
# Test that the component renders with correct attributes
|
||||
|
|
@ -18,7 +32,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
|
|||
end
|
||||
|
||||
test "renders all sortable headers", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
conn = conn |> conn_with_oidc_user() |> non_compact()
|
||||
{:ok, view, _html} = live(conn, "/members?fields=#{@cols}")
|
||||
|
||||
sortable_fields = [
|
||||
|
|
@ -38,7 +52,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
|
|||
end
|
||||
|
||||
test "renders correct labels", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
conn = conn |> conn_with_oidc_user() |> non_compact()
|
||||
{:ok, view, _html} = live(conn, "/members?fields=#{@cols}")
|
||||
|
||||
# Test specific labels
|
||||
|
|
@ -50,7 +64,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
|
|||
|
||||
describe "sort icons" do
|
||||
test "shows neutral icon for specific field when not sorted", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
conn = conn |> conn_with_oidc_user() |> non_compact()
|
||||
{:ok, view, _html} = live(conn, "/members?fields=#{@cols}")
|
||||
|
||||
# The neutral icon has the opcity class we can test for
|
||||
|
|
@ -62,7 +76,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
|
|||
end
|
||||
|
||||
test "shows ascending icon for specific field when sorted ascending", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
conn = conn |> conn_with_oidc_user() |> non_compact()
|
||||
|
||||
{:ok, view, html} =
|
||||
live(conn, "/members?fields=#{@cols}&query=&sort_field=city&sort_order=asc")
|
||||
|
|
@ -85,7 +99,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
|
|||
end
|
||||
|
||||
test "shows descending icon for specific field when sorted descending", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
conn = conn |> conn_with_oidc_user() |> non_compact()
|
||||
|
||||
{:ok, _view, html} =
|
||||
live(conn, "/members?fields=#{@cols}&query=&sort_field=email&sort_order=desc")
|
||||
|
|
@ -99,7 +113,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
|
|||
end
|
||||
|
||||
test "multiple fields can have different icon states", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
conn = conn |> conn_with_oidc_user() |> non_compact()
|
||||
|
||||
{:ok, view, _html} =
|
||||
live(conn, "/members?fields=#{@cols}&query=&sort_field=city&sort_order=asc")
|
||||
|
|
@ -118,7 +132,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
|
|||
end
|
||||
|
||||
test "icon state changes correctly when clicking different fields", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
conn = conn |> conn_with_oidc_user() |> non_compact()
|
||||
{:ok, view, _html} = live(conn, "/members?fields=#{@cols}")
|
||||
|
||||
# Start: all fields neutral except first name as default
|
||||
|
|
@ -146,7 +160,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
|
|||
end
|
||||
|
||||
test "specific field shows correct icon for each sort state", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
conn = conn |> conn_with_oidc_user() |> non_compact()
|
||||
|
||||
# Test EMAIL field specifically
|
||||
{:ok, view, html_asc} =
|
||||
|
|
@ -167,7 +181,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
|
|||
end
|
||||
|
||||
test "icon distribution shows exactly one active sort icon", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
conn = conn |> conn_with_oidc_user() |> non_compact()
|
||||
|
||||
# Test neutral state - only one field should have active sort icon
|
||||
{:ok, _view, html_neutral} = live(conn, "/members?fields=#{@cols}")
|
||||
|
|
@ -204,7 +218,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
|
|||
|
||||
describe "accessibility" do
|
||||
test "sets aria-label correctly for unsorted state", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
conn = conn |> conn_with_oidc_user() |> non_compact()
|
||||
{:ok, view, _html} = live(conn, "/members?fields=#{@cols}")
|
||||
|
||||
# Check aria-label for unsorted state
|
||||
|
|
@ -212,7 +226,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
|
|||
end
|
||||
|
||||
test "sets aria-label correctly for ascending sort", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
conn = conn |> conn_with_oidc_user() |> non_compact()
|
||||
|
||||
{:ok, view, _html} =
|
||||
live(conn, "/members?fields=#{@cols}&sort_field=first_name&sort_order=asc")
|
||||
|
|
@ -222,7 +236,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
|
|||
end
|
||||
|
||||
test "sets aria-label correctly for descending sort", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
conn = conn |> conn_with_oidc_user() |> non_compact()
|
||||
|
||||
{:ok, view, _html} =
|
||||
live(conn, "/members?fields=#{@cols}&sort_field=first_name&sort_order=desc")
|
||||
|
|
@ -232,7 +246,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
|
|||
end
|
||||
|
||||
test "includes tooltip with correct aria-label", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
conn = conn |> conn_with_oidc_user() |> non_compact()
|
||||
|
||||
{:ok, view, _html} =
|
||||
live(conn, "/members?fields=#{@cols}&sort_field=first_name&sort_order=asc")
|
||||
|
|
@ -243,7 +257,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
|
|||
end
|
||||
|
||||
test "aria-labels work for all sortable fields", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
conn = conn |> conn_with_oidc_user() |> non_compact()
|
||||
{:ok, view, _html} = live(conn, "/members?fields=#{@cols}&sort_field=email&sort_order=desc")
|
||||
|
||||
# Test aria-labels for different fields
|
||||
|
|
@ -260,7 +274,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
|
|||
|
||||
describe "component behavior" do
|
||||
test "clicking triggers sort event on parent LiveView", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
conn = conn |> conn_with_oidc_user() |> non_compact()
|
||||
{:ok, view, _html} = live(conn, "/members?fields=#{@cols}")
|
||||
|
||||
# Click on the first name sort header
|
||||
|
|
@ -273,7 +287,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
|
|||
end
|
||||
|
||||
test "component handles different field types correctly", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
conn = conn |> conn_with_oidc_user() |> non_compact()
|
||||
{:ok, view, _html} = live(conn, "/members?fields=#{@cols}")
|
||||
|
||||
# Test that different field types render correctly
|
||||
|
|
@ -285,7 +299,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
|
|||
|
||||
describe "edge cases" do
|
||||
test "handles invalid sort field gracefully", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
conn = conn |> conn_with_oidc_user() |> non_compact()
|
||||
|
||||
{:ok, view, html} =
|
||||
live(conn, "/members?fields=#{@cols}&sort_field=invalid_field&sort_order=asc")
|
||||
|
|
@ -296,7 +310,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
|
|||
end
|
||||
|
||||
test "handles invalid sort order gracefully", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
conn = conn |> conn_with_oidc_user() |> non_compact()
|
||||
|
||||
{:ok, view, html} =
|
||||
live(conn, "/members?fields=#{@cols}&sort_field=first_name&sort_order=invalid")
|
||||
|
|
@ -307,7 +321,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
|
|||
end
|
||||
|
||||
test "handles empty sort parameters", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
conn = conn |> conn_with_oidc_user() |> non_compact()
|
||||
{:ok, view, html} = live(conn, "/members?fields=#{@cols}&sort_field=&sort_order=")
|
||||
|
||||
# Should show neutral icons
|
||||
|
|
@ -318,7 +332,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
|
|||
|
||||
describe "icon state transitions" do
|
||||
test "icon changes when sorting state changes", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
conn = conn |> conn_with_oidc_user() |> non_compact()
|
||||
{:ok, view, _html} = live(conn, "/members?fields=#{@cols}")
|
||||
|
||||
# Start with neutral state
|
||||
|
|
@ -334,7 +348,7 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
|
|||
end
|
||||
|
||||
test "multiple fields can be tested for icon states", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
conn = conn |> conn_with_oidc_user() |> non_compact()
|
||||
{:ok, view, html} = live(conn, "/members?fields=#{@cols}&sort_field=email&sort_order=desc")
|
||||
|
||||
# Email should be active (descending)
|
||||
|
|
|
|||
|
|
@ -201,6 +201,56 @@ defmodule MvWeb.MemberLive.Index.FieldVisibilityTest do
|
|||
end
|
||||
end
|
||||
|
||||
describe "offered_member_fields/3 ordering (name group, then email, then address group, then rest)" do
|
||||
test "compact member + compact address: Name, E-Mail, Adresse lead" do
|
||||
result = FieldVisibility.offered_member_fields(true, false, true)
|
||||
|
||||
assert Enum.take(result, 3) == [:name, :email, :address]
|
||||
# the composite constituents are not offered while their composite is on
|
||||
refute :first_name in result
|
||||
refute :street in result
|
||||
# the Datenfelder-ordered rest trails the identity block
|
||||
assert index_of(result, :address) < index_of(result, :join_date)
|
||||
end
|
||||
|
||||
test "compact member with email folded in: no separate E-Mail column" do
|
||||
result = FieldVisibility.offered_member_fields(true, true, true)
|
||||
|
||||
assert Enum.take(result, 2) == [:name, :address]
|
||||
refute :email in result
|
||||
end
|
||||
|
||||
test "non-compact member + compact address: Vorname/Nachname/E-Mail before Adresse" do
|
||||
result = FieldVisibility.offered_member_fields(false, false, true)
|
||||
|
||||
# the reported bug: the composite address must not jump ahead of the
|
||||
# expanded name constituents
|
||||
assert Enum.take(result, 4) == [:first_name, :last_name, :email, :address]
|
||||
assert index_of(result, :address) < index_of(result, :join_date)
|
||||
end
|
||||
|
||||
test "non-compact member + non-compact address: name, email, then the address block, then rest" do
|
||||
result = FieldVisibility.offered_member_fields(false, false, false)
|
||||
|
||||
assert Enum.take(result, 7) == [
|
||||
:first_name,
|
||||
:last_name,
|
||||
:email,
|
||||
:street,
|
||||
:house_number,
|
||||
:postal_code,
|
||||
:city
|
||||
]
|
||||
|
||||
# join_date and later Datenfelder columns never precede the address block
|
||||
assert index_of(result, :city) < index_of(result, :join_date)
|
||||
refute :name in result
|
||||
refute :address in result
|
||||
end
|
||||
end
|
||||
|
||||
defp index_of(list, elem), do: Enum.find_index(list, &(&1 == elem))
|
||||
|
||||
describe "get_visible_fields/1" do
|
||||
test "returns only fields with true visibility" do
|
||||
selection = %{
|
||||
|
|
|
|||
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
|
||||
@moduledoc """
|
||||
§1.2 — With no persisted column selection, exactly the curated default columns
|
||||
are visible: selection checkbox, Name (name + email), Address (composite),
|
||||
fee type, fee status, groups, join date.
|
||||
are visible: selection checkbox, Name (composite, no in-cell email line),
|
||||
E-Mail (its own column while the compact Member field keeps the email line off),
|
||||
Address (composite), fee type, fee status, groups, join date.
|
||||
"""
|
||||
use MvWeb.ConnCase, async: false
|
||||
|
||||
|
|
@ -34,6 +35,8 @@ defmodule MvWeb.MemberLive.IndexDefaultColumnsTest do
|
|||
# Composite Name + Address cells.
|
||||
assert has_element?(view, "[data-testid='member-name']")
|
||||
assert has_element?(view, "[data-testid='member-address']")
|
||||
# E-Mail is its own column by default (compact Member field, email line off).
|
||||
assert has_element?(view, "[data-testid='email']")
|
||||
# Fee type, fee status, groups, join date headers.
|
||||
assert has_element?(view, "[data-testid='membership_fee_type']")
|
||||
assert has_element?(view, "[data-testid='join_date']")
|
||||
|
|
@ -44,7 +47,9 @@ defmodule MvWeb.MemberLive.IndexDefaultColumnsTest do
|
|||
test "the individual name/address sub-fields are hidden by default", %{conn: conn} do
|
||||
{:ok, view, _html} = live(conn, ~p"/members")
|
||||
|
||||
for field <- ~w(first_name last_name email city street house_number postal_code country) do
|
||||
# The composite Name/Address cells replace their constituents by default.
|
||||
# E-Mail is excluded here: it is a default-visible column of its own.
|
||||
for field <- ~w(first_name last_name city street house_number postal_code country) do
|
||||
refute has_element?(view, "[data-testid='#{field}']")
|
||||
end
|
||||
end
|
||||
|
|
|
|||
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
|
||||
|
||||
# Disables the compact Member/Address composites (per-browser view setting) so
|
||||
# the individual constituent columns (first_name/last_name/email, street/…) are
|
||||
# offered by the column manager and available via ?fields=.
|
||||
defp non_compact(conn) do
|
||||
Plug.Conn.put_session(
|
||||
conn,
|
||||
"member_view_settings",
|
||||
~s({"compact_member":false,"compact_address":false})
|
||||
)
|
||||
end
|
||||
|
||||
setup do
|
||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||
|
||||
|
|
@ -112,7 +123,9 @@ defmodule MvWeb.MemberLive.IndexFieldVisibilityTest do
|
|||
end
|
||||
|
||||
test "displays all member fields in dropdown", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
# The individual name/address constituents are only offered when the
|
||||
# compact Member/Address composites are off, so opt out of them first.
|
||||
conn = conn |> conn_with_oidc_user() |> non_compact()
|
||||
{:ok, view, _html} = live(conn, "/members")
|
||||
|
||||
# Open dropdown
|
||||
|
|
@ -148,9 +161,9 @@ defmodule MvWeb.MemberLive.IndexFieldVisibilityTest do
|
|||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, view, _html} = live(conn, "/members")
|
||||
|
||||
# The curated Name column carries the email, so it is visible initially.
|
||||
# The curated Name column carries the member names, so they are visible.
|
||||
html = render(view)
|
||||
assert html =~ "alice@example.com"
|
||||
assert html =~ "Anderson"
|
||||
|
||||
# Open dropdown and hide the Name column
|
||||
view
|
||||
|
|
@ -161,10 +174,10 @@ defmodule MvWeb.MemberLive.IndexFieldVisibilityTest do
|
|||
|> element("button[phx-click='select_item'][phx-value-item='name']")
|
||||
|> render_click()
|
||||
|
||||
# The Name column (and the email it carries) is no longer visible
|
||||
# The Name column (and the names it carries) is no longer visible
|
||||
html = render(view)
|
||||
refute html =~ "alice@example.com"
|
||||
refute html =~ "bob@example.com"
|
||||
refute html =~ "Anderson"
|
||||
refute html =~ "Brown"
|
||||
end
|
||||
|
||||
test "hiding custom field removes it from display", %{conn: conn, custom_field: custom_field} do
|
||||
|
|
@ -298,8 +311,8 @@ defmodule MvWeb.MemberLive.IndexFieldVisibilityTest do
|
|||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, _view, html} = live(conn, "/members")
|
||||
|
||||
# All fields should be visible by default
|
||||
assert html =~ "alice@example.com"
|
||||
# The curated Name and Address columns are visible by default.
|
||||
assert html =~ "Anderson"
|
||||
assert html =~ "Main St"
|
||||
end
|
||||
|
||||
|
|
@ -309,7 +322,7 @@ defmodule MvWeb.MemberLive.IndexFieldVisibilityTest do
|
|||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, view, _html} = live(conn, "/members")
|
||||
|
||||
# Hide the curated Name column (which carries the email) via dropdown
|
||||
# Hide the curated Name column (which carries the names) via dropdown
|
||||
view
|
||||
|> element("button[aria-controls='field-visibility-menu']")
|
||||
|> render_click()
|
||||
|
|
@ -319,7 +332,7 @@ defmodule MvWeb.MemberLive.IndexFieldVisibilityTest do
|
|||
|> render_click()
|
||||
|
||||
html = render(view)
|
||||
refute html =~ "alice@example.com"
|
||||
refute html =~ "Anderson"
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -328,16 +341,16 @@ defmodule MvWeb.MemberLive.IndexFieldVisibilityTest do
|
|||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, _view, html} = live(conn, "/members?fields=")
|
||||
|
||||
# Should fall back to global settings
|
||||
assert html =~ "alice@example.com"
|
||||
# Should fall back to global settings (curated Name column visible)
|
||||
assert html =~ "Anderson"
|
||||
end
|
||||
|
||||
test "handles invalid field names in URL", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, _view, html} = live(conn, "/members?fields=invalid_field,another_invalid")
|
||||
|
||||
# Should ignore invalid fields and use defaults
|
||||
assert html =~ "alice@example.com"
|
||||
# Should ignore invalid fields and use defaults (curated Name column visible)
|
||||
assert html =~ "Anderson"
|
||||
end
|
||||
|
||||
test "handles custom field that doesn't exist", %{conn: conn} do
|
||||
|
|
@ -429,9 +442,9 @@ defmodule MvWeb.MemberLive.IndexFieldVisibilityTest do
|
|||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, view, _html} = live(conn, "/members")
|
||||
|
||||
# The curated Name column carries the email, so it is visible initially.
|
||||
# The curated Name column carries the names, so they are visible initially.
|
||||
html = render(view)
|
||||
assert html =~ "alice@example.com"
|
||||
assert html =~ "Anderson"
|
||||
|
||||
# Open dropdown
|
||||
view
|
||||
|
|
@ -443,9 +456,9 @@ defmodule MvWeb.MemberLive.IndexFieldVisibilityTest do
|
|||
|> element("button[phx-click='select_item'][phx-value-item='name']")
|
||||
|> render_keydown(%{key: "Enter"})
|
||||
|
||||
# The Name column (and the email it carries) is no longer visible
|
||||
# The Name column (and the names it carries) is no longer visible
|
||||
html = render(view)
|
||||
refute html =~ "alice@example.com"
|
||||
refute html =~ "Anderson"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -20,6 +20,16 @@ defmodule MvWeb.MemberLive.IndexGroupsUrlParamsTest do
|
|||
|
||||
require Ash.Query
|
||||
|
||||
# Disables the compact Member composite (per-browser view setting) so the
|
||||
# individual first_name column is offered and selectable via ?fields=.
|
||||
defp non_compact(conn) do
|
||||
Plug.Conn.put_session(
|
||||
conn,
|
||||
"member_view_settings",
|
||||
~s({"compact_member":false,"compact_address":false})
|
||||
)
|
||||
end
|
||||
|
||||
setup do
|
||||
system_actor = Mv.Helpers.SystemActor.get_system_actor()
|
||||
|
||||
|
|
@ -122,7 +132,7 @@ defmodule MvWeb.MemberLive.IndexGroupsUrlParamsTest do
|
|||
member1: member1,
|
||||
group1: group1
|
||||
} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
conn = conn |> conn_with_oidc_user() |> non_compact()
|
||||
|
||||
{:ok, view, html} =
|
||||
live(
|
||||
|
|
|
|||
|
|
@ -38,6 +38,9 @@ defmodule MvWeb.MemberLive.IndexMemberFieldsDisplayTest do
|
|||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, _view, html} = live(conn, "/members")
|
||||
|
||||
# The composite Member cell carries first + last name; the in-cell email
|
||||
# line stays off by default, but the email is surfaced in its own column
|
||||
# (compact Member field, include-email off).
|
||||
for m <- [m1, m2], field <- [m.first_name, m.last_name, m.email] do
|
||||
assert html =~ field
|
||||
end
|
||||
|
|
|
|||
|
|
@ -13,6 +13,19 @@ defmodule MvWeb.MemberLive.IndexTest do
|
|||
alias Mv.Membership.CustomFieldValue
|
||||
alias MvWeb.MemberLive.Index, as: MemberIndex
|
||||
|
||||
# Disables the compact Member/Address composites (per-browser view setting) so
|
||||
# the individual constituent columns (first_name/last_name, street/…) are
|
||||
# offered by the column manager and selectable via ?fields=. The composite
|
||||
# E-Mail column is offered even in compact mode, so email-only tests do not
|
||||
# need this.
|
||||
defp non_compact(conn) do
|
||||
Plug.Conn.put_session(
|
||||
conn,
|
||||
"member_view_settings",
|
||||
~s({"compact_member":false,"compact_address":false})
|
||||
)
|
||||
end
|
||||
|
||||
describe "desktop layout: scroll container and sticky table header" do
|
||||
@describetag :ui
|
||||
|
||||
|
|
@ -155,9 +168,11 @@ defmodule MvWeb.MemberLive.IndexTest do
|
|||
describe "sorting integration" do
|
||||
@describetag :ui
|
||||
# The curated default columns no longer expose the individual name/address
|
||||
# sort headers, so these tests make the relevant column visible via ?fields=.
|
||||
# That puts a `fields` param on every push_patch, hence the relaxed
|
||||
# substring assertions instead of exact patch strings.
|
||||
# sort headers. The composite E-Mail column is offered even in compact mode
|
||||
# (so email-only tests just add it via ?fields=), but the name/address
|
||||
# constituents are only offered once their composite view setting is off —
|
||||
# those tests additionally opt out via `non_compact/1`. Either way a `fields`
|
||||
# param rides on every push_patch, hence the relaxed substring assertions.
|
||||
test "clicking a column header toggles sort order and updates the URL", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, view, _html} = live(conn, "/members?fields=email")
|
||||
|
|
@ -183,7 +198,7 @@ defmodule MvWeb.MemberLive.IndexTest do
|
|||
end
|
||||
|
||||
test "clicking different column header resets order to ascending", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
conn = conn |> conn_with_oidc_user() |> non_compact()
|
||||
|
||||
{:ok, view, _html} =
|
||||
live(conn, "/members?fields=first_name,email&sort_field=email&sort_order=desc")
|
||||
|
|
@ -199,7 +214,7 @@ defmodule MvWeb.MemberLive.IndexTest do
|
|||
end
|
||||
|
||||
test "all sortable columns work correctly", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
conn = conn |> conn_with_oidc_user() |> non_compact()
|
||||
|
||||
{:ok, view, _html} =
|
||||
live(
|
||||
|
|
@ -275,7 +290,7 @@ defmodule MvWeb.MemberLive.IndexTest do
|
|||
end
|
||||
|
||||
test "handle_params handles invalid sort field gracefully", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
conn = conn |> conn_with_oidc_user() |> non_compact()
|
||||
|
||||
{:ok, view, _html} =
|
||||
live(conn, "/members?fields=first_name&query=&sort_field=invalid_field&sort_order=asc")
|
||||
|
|
|
|||
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