mitgliederverwaltung/lib/mv_web/live/components/field_visibility_dropdown_component.ex
Simon dfc616257d 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.
2026-07-06 10:45:53 +02:00

203 lines
6 KiB
Elixir

defmodule MvWeb.Components.FieldVisibilityDropdownComponent do
@moduledoc """
LiveComponent for managing field visibility in the member overview.
Provides an accessible dropdown menu where users can select/deselect
which member fields and custom fields are visible in the table.
## Props
- `:all_fields` - List of all available fields
- `:custom_fields` - List of CustomField resources
- `:selected_fields` - Map field_name → boolean
- `:id` - Component ID
## Events sent to parent:
- `{:field_toggled, field, value}`
- `{:fields_selected, map}`
"""
use MvWeb, :live_component
alias MvWeb.Translations.MemberFields
# ---------------------------------------------------------------------------
# UPDATE
# ---------------------------------------------------------------------------
@impl true
def update(assigns, socket) do
socket =
socket
|> assign(assigns)
|> assign_new(:open, fn -> false end)
|> assign_new(:all_fields, fn -> [] end)
|> assign_new(:custom_fields, fn -> [] end)
|> assign_new(:selected_fields, fn -> %{} end)
{:ok, socket}
end
# ---------------------------------------------------------------------------
# RENDER
# ---------------------------------------------------------------------------
@impl true
def render(assigns) do
all_fields = assigns.all_fields || []
custom_fields = assigns.custom_fields || []
all_items =
(Enum.map(extract_member_field_keys(all_fields), fn field ->
%{
value: field_to_string(field),
label: format_field_label(field)
}
end) ++
Enum.map(extract_custom_field_keys(all_fields), fn field ->
%{
value: field,
label: format_custom_field_label(field, custom_fields)
}
end))
|> Enum.uniq_by(fn item -> item.value end)
assigns = assign(assigns, :all_items, all_items)
# LiveComponents require a static HTML element as root, not a function component
~H"""
<div>
<.dropdown_menu
id="field-visibility-menu"
icon="hero-adjustments-horizontal"
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>
"""
end
# ---------------------------------------------------------------------------
# EVENTS (matching the Core Component API)
# ---------------------------------------------------------------------------
@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
# toggle single item
def handle_event("select_item", %{"item" => item}, socket) do
current = Map.get(socket.assigns.selected_fields, item, true)
updated = Map.put(socket.assigns.selected_fields, item, !current)
send(self(), {:field_toggled, item, !current})
{:noreply, assign(socket, :selected_fields, updated)}
end
# select all
def handle_event("select_all", _params, socket) do
all =
socket.assigns.all_fields
|> Enum.map(&field_to_string/1)
|> Enum.map(&{&1, true})
|> Enum.into(%{})
send(self(), {:fields_selected, all})
{: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 =
socket.assigns.all_fields
|> Enum.map(&field_to_string/1)
|> Enum.map(&{&1, false})
|> Enum.into(%{})
send(self(), {:fields_selected, none})
{:noreply, assign(socket, :selected_fields, none)}
end
# ---------------------------------------------------------------------------
# HELPERS (with defensive nil guards)
# ---------------------------------------------------------------------------
defp extract_member_field_keys(nil), do: []
defp extract_member_field_keys(fields) do
prefix = Mv.Constants.custom_field_prefix()
Enum.filter(fields, fn field ->
is_atom(field) ||
(is_binary(field) && not String.starts_with?(field, prefix))
end)
end
defp extract_custom_field_keys(nil), do: []
defp extract_custom_field_keys(fields) do
prefix = Mv.Constants.custom_field_prefix()
Enum.filter(fields, fn field ->
is_binary(field) && String.starts_with?(field, prefix)
end)
end
defp field_to_string(field) when is_atom(field), do: Atom.to_string(field)
defp field_to_string(field) when is_binary(field), do: field
defp format_field_label(field) when is_atom(field) do
MemberFields.label(field)
end
defp format_field_label(field) when is_binary(field) do
case safe_to_existing_atom(field) do
{:ok, atom} -> MemberFields.label(atom)
:error -> fallback_label(field)
end
end
defp safe_to_existing_atom(string) do
{:ok, String.to_existing_atom(string)}
rescue
ArgumentError -> :error
end
defp fallback_label(field) do
field
|> String.replace("_", " ")
|> String.split()
|> Enum.map_join(" ", &String.capitalize/1)
end
defp format_custom_field_label(field_string, custom_fields) do
id = String.trim_leading(field_string, Mv.Constants.custom_field_prefix())
find_custom_field_name(id, field_string, custom_fields)
end
defp find_custom_field_name("", field_string, _custom_fields), do: field_string
defp find_custom_field_name(id, _field_string, custom_fields) do
case Enum.find(custom_fields, fn cf -> to_string(cf.id) == id end) do
nil -> gettext("Datafield %{id}", id: id)
custom_field -> custom_field.name
end
end
end