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

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