feat(member): let members tailor overview density and visible columns

The View dropdown drives row density and whether the Member and Address
fields render as composite cells or split into their underlying columns;
the column manager toggles visibility and resets to the curated default.
All choices persist per browser through the URL/session/cookie/global
chain, so a saved layout survives reloads without a per-account store.
This commit is contained in:
Simon 2026-07-06 10:45:53 +02:00
parent af2cc2e0d4
commit dfc616257d
21 changed files with 1643 additions and 263 deletions

View file

@ -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 =

View file

@ -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>

View 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