feat: adds field visibility dropdown live component
Some checks failed
continuous-integration/drone/push Build is failing
Some checks failed
continuous-integration/drone/push Build is failing
This commit is contained in:
parent
45a9bc0cc0
commit
0fb43a0816
6 changed files with 981 additions and 33 deletions
|
|
@ -111,6 +111,126 @@ defmodule MvWeb.CoreComponents do
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Renders a dropdown menu.
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
<.dropdown_menu items={@items} open={@open} phx-target={@myself} />
|
||||||
|
"""
|
||||||
|
attr :id, :string, default: "dropdown-menu"
|
||||||
|
attr :items, :list, required: true, doc: "List of %{label: string, value: any} maps"
|
||||||
|
attr :button_label, :string, default: "Dropdown"
|
||||||
|
attr :icon, :string, default: nil
|
||||||
|
attr :checkboxes, :boolean, default: false
|
||||||
|
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 :phx_target, :any, default: nil
|
||||||
|
|
||||||
|
def dropdown_menu(assigns) do
|
||||||
|
unless Map.has_key?(assigns, :phx_target) do
|
||||||
|
raise ArgumentError, ":phx_target is required in dropdown_menu/1"
|
||||||
|
end
|
||||||
|
|
||||||
|
assigns =
|
||||||
|
assign_new(assigns, :items, fn -> [] end)
|
||||||
|
|> assign_new(:button_label, fn -> "Dropdown" end)
|
||||||
|
|> assign_new(:icon, fn -> nil end)
|
||||||
|
|> assign_new(:checkboxes, fn -> false end)
|
||||||
|
|> assign_new(:selected, fn -> %{} end)
|
||||||
|
|> assign_new(:open, fn -> false end)
|
||||||
|
|> assign_new(:show_select_buttons, fn -> false end)
|
||||||
|
|> assign(:phx_target, assigns.phx_target)
|
||||||
|
|> assign_new(:id, fn -> "dropdown-menu" end)
|
||||||
|
|
||||||
|
~H"""
|
||||||
|
<div class="relative" phx-click-away="close_dropdown" phx-target={@phx_target}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
tabindex="0"
|
||||||
|
role="button"
|
||||||
|
aria-haspopup="menu"
|
||||||
|
aria-expanded={@open}
|
||||||
|
aria-controls={@id}
|
||||||
|
class="btn btn-ghost"
|
||||||
|
phx-click="toggle_dropdown"
|
||||||
|
phx-target={@phx_target}
|
||||||
|
>
|
||||||
|
<%= if @icon do %><.icon name={@icon} /><% end %>
|
||||||
|
<span><%= @button_label %></span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<ul
|
||||||
|
:if={@open}
|
||||||
|
id={@id}
|
||||||
|
role="menu"
|
||||||
|
class="absolute right-0 mt-2 bg-base-100 z-[100] p-2 shadow-lg rounded-box w-64 max-h-96 overflow-y-auto border border-base-300"
|
||||||
|
tabindex="0"
|
||||||
|
phx-window-keydown="close_dropdown"
|
||||||
|
phx-key="Escape"
|
||||||
|
phx-target={@phx_target}
|
||||||
|
>
|
||||||
|
<li :if={@show_select_buttons} role="none">
|
||||||
|
<div class="flex justify-between items-center mb-2 px-2">
|
||||||
|
<span class="font-semibold">{gettext("Options")}</span>
|
||||||
|
<div class="flex gap-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="menuitem"
|
||||||
|
aria-label={gettext("Select all")}
|
||||||
|
phx-click="select_all"
|
||||||
|
phx-target={@phx_target}
|
||||||
|
class="btn btn-xs btn-ghost"
|
||||||
|
>
|
||||||
|
{gettext("All")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="menuitem"
|
||||||
|
aria-label={gettext("Select none")}
|
||||||
|
phx-click="select_none"
|
||||||
|
phx-target={@phx_target}
|
||||||
|
class="btn btn-xs btn-ghost"
|
||||||
|
>
|
||||||
|
{gettext("None")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li :if={@show_select_buttons} role="separator" class="divider my-1"></li>
|
||||||
|
|
||||||
|
<%= for item <- @items do %>
|
||||||
|
<li role="none">
|
||||||
|
<label
|
||||||
|
role={if @checkboxes, do: "menuitemcheckbox", else: "menuitem"}
|
||||||
|
aria-checked={@checkboxes && Map.get(@selected, item.value, true)}
|
||||||
|
tabindex="0"
|
||||||
|
class="flex items-center gap-2 px-2 py-1 rounded cursor-pointer hover:bg-base-200"
|
||||||
|
phx-click="select_item"
|
||||||
|
phx-value-item={item.value}
|
||||||
|
phx-target={@phx_target}
|
||||||
|
>
|
||||||
|
<%= if @checkboxes do %>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
class="checkbox checkbox-sm"
|
||||||
|
checked={Map.get(@selected, item.value, true)}
|
||||||
|
tabindex="-1"
|
||||||
|
aria-hidden="true"
|
||||||
|
readonly
|
||||||
|
/>
|
||||||
|
<% end %>
|
||||||
|
<span><%= item.label %></span>
|
||||||
|
</label>
|
||||||
|
</li>
|
||||||
|
<% end %>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
"""
|
||||||
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Renders an input with label and error messages.
|
Renders an input with label and error messages.
|
||||||
|
|
||||||
|
|
|
||||||
172
lib/mv_web/components/field_visibility_dropdown_component.ex
Normal file
172
lib/mv_web/components/field_visibility_dropdown_component.ex
Normal file
|
|
@ -0,0 +1,172 @@
|
||||||
|
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
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 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(member_fields(all_fields), fn field ->
|
||||||
|
%{
|
||||||
|
value: field_to_string(field),
|
||||||
|
label: format_field_label(field)
|
||||||
|
}
|
||||||
|
end) ++
|
||||||
|
Enum.map(custom_fields(all_fields), fn field ->
|
||||||
|
%{
|
||||||
|
value: field,
|
||||||
|
label: format_custom_field_label(field, custom_fields)
|
||||||
|
}
|
||||||
|
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}
|
||||||
|
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
|
||||||
|
|
||||||
|
# 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 member_fields(nil), do: []
|
||||||
|
|
||||||
|
defp member_fields(fields) do
|
||||||
|
Enum.filter(fields, fn field ->
|
||||||
|
is_atom(field) ||
|
||||||
|
(is_binary(field) && not String.starts_with?(field, "custom_field_"))
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp custom_fields(nil), do: []
|
||||||
|
|
||||||
|
defp custom_fields(fields) do
|
||||||
|
Enum.filter(fields, fn field ->
|
||||||
|
is_binary(field) && String.starts_with?(field, "custom_field_")
|
||||||
|
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) do
|
||||||
|
field
|
||||||
|
|> field_to_string()
|
||||||
|
|> String.replace("_", " ")
|
||||||
|
|> String.split()
|
||||||
|
|> Enum.map(&String.capitalize/1)
|
||||||
|
|> Enum.join(" ")
|
||||||
|
end
|
||||||
|
|
||||||
|
defp format_custom_field_label(field_string, custom_fields) do
|
||||||
|
case String.trim_leading(field_string, "custom_field_") do
|
||||||
|
"" ->
|
||||||
|
field_string
|
||||||
|
|
||||||
|
id ->
|
||||||
|
case Enum.find(custom_fields, fn cf -> to_string(cf.id) == id end) do
|
||||||
|
nil -> gettext("Custom Field %{id}", id: id)
|
||||||
|
custom_field -> custom_field.name
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
@ -31,6 +31,8 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
|
|
||||||
alias Mv.Membership
|
alias Mv.Membership
|
||||||
alias MvWeb.MemberLive.Index.Formatter
|
alias MvWeb.MemberLive.Index.Formatter
|
||||||
|
alias MvWeb.MemberLive.Index.FieldSelection
|
||||||
|
alias MvWeb.MemberLive.Index.FieldVisibility
|
||||||
|
|
||||||
# Prefix used in sort field names for custom fields (e.g., "custom_field_<id>")
|
# Prefix used in sort field names for custom fields (e.g., "custom_field_<id>")
|
||||||
@custom_field_prefix "custom_field_"
|
@custom_field_prefix "custom_field_"
|
||||||
|
|
@ -48,8 +50,8 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
and member selection. Actual data loading happens in `handle_params/3`.
|
and member selection. Actual data loading happens in `handle_params/3`.
|
||||||
"""
|
"""
|
||||||
@impl true
|
@impl true
|
||||||
def mount(_params, _session, socket) do
|
def mount(_params, session, socket) do
|
||||||
# Load custom fields that should be shown in overview
|
# Load custom fields that should be shown in overview (for display)
|
||||||
# Note: Using Ash.read! (bang version) - errors will be handled by Phoenix LiveView
|
# Note: Using Ash.read! (bang version) - errors will be handled by Phoenix LiveView
|
||||||
# and result in a 500 error page. This is appropriate for LiveViews where errors
|
# and result in a 500 error page. This is appropriate for LiveViews where errors
|
||||||
# should be visible to the user rather than silently failing.
|
# should be visible to the user rather than silently failing.
|
||||||
|
|
@ -59,6 +61,12 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
|> Ash.Query.sort(name: :asc)
|
|> Ash.Query.sort(name: :asc)
|
||||||
|> Ash.read!()
|
|> Ash.read!()
|
||||||
|
|
||||||
|
# Load ALL custom fields for the dropdown (to show all available fields)
|
||||||
|
all_custom_fields =
|
||||||
|
Mv.Membership.CustomField
|
||||||
|
|> Ash.Query.sort(name: :asc)
|
||||||
|
|> Ash.read!()
|
||||||
|
|
||||||
# Load settings once to avoid N+1 queries
|
# Load settings once to avoid N+1 queries
|
||||||
settings =
|
settings =
|
||||||
case Membership.get_settings() do
|
case Membership.get_settings() do
|
||||||
|
|
@ -67,6 +75,20 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
{:error, _} -> %{member_field_visibility: %{}}
|
{:error, _} -> %{member_field_visibility: %{}}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Load user field selection from session
|
||||||
|
session_selection = FieldSelection.get_from_session(session)
|
||||||
|
|
||||||
|
# Get all available fields (for dropdown - includes ALL custom fields)
|
||||||
|
all_available_fields = FieldVisibility.get_all_available_fields(all_custom_fields)
|
||||||
|
|
||||||
|
# Merge session selection with global settings for initial state (use all_custom_fields)
|
||||||
|
initial_selection =
|
||||||
|
FieldVisibility.merge_with_global_settings(
|
||||||
|
session_selection,
|
||||||
|
settings,
|
||||||
|
all_custom_fields
|
||||||
|
)
|
||||||
|
|
||||||
socket =
|
socket =
|
||||||
socket
|
socket
|
||||||
|> assign(:page_title, gettext("Members"))
|
|> assign(:page_title, gettext("Members"))
|
||||||
|
|
@ -76,8 +98,14 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
|> assign(:selected_members, [])
|
|> assign(:selected_members, [])
|
||||||
|> assign(:settings, settings)
|
|> assign(:settings, settings)
|
||||||
|> assign(:custom_fields_visible, custom_fields_visible)
|
|> assign(:custom_fields_visible, custom_fields_visible)
|
||||||
|
|> assign(:all_custom_fields, all_custom_fields)
|
||||||
|
|> assign(:all_available_fields, all_available_fields)
|
||||||
|
|> assign(:user_field_selection, initial_selection)
|
||||||
|> assign(:member_field_configurations, get_member_field_configurations(settings))
|
|> assign(:member_field_configurations, get_member_field_configurations(settings))
|
||||||
|> assign(:member_fields_visible, get_visible_member_fields(settings))
|
|> assign(
|
||||||
|
:member_fields_visible,
|
||||||
|
FieldVisibility.get_visible_member_fields(initial_selection)
|
||||||
|
)
|
||||||
|
|
||||||
# We call handle params to use the query from the URL
|
# We call handle params to use the query from the URL
|
||||||
{:ok, socket}
|
{:ok, socket}
|
||||||
|
|
@ -144,6 +172,8 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
## Supported messages:
|
## Supported messages:
|
||||||
- `{:sort, field}` - Sort event from SortHeaderComponent. Updates sort field/order and syncs URL
|
- `{:sort, field}` - Sort event from SortHeaderComponent. Updates sort field/order and syncs URL
|
||||||
- `{:search_changed, query}` - Search event from SearchBarComponent. Filters members and syncs URL
|
- `{:search_changed, query}` - Search event from SearchBarComponent. Filters members and syncs URL
|
||||||
|
- `{:field_toggled, field, visible}` - Field toggle event from FieldVisibilityDropdownComponent
|
||||||
|
- `{:fields_selected, selection}` - Select all/deselect all event from FieldVisibilityDropdownComponent
|
||||||
"""
|
"""
|
||||||
@impl true
|
@impl true
|
||||||
def handle_info({:sort, field_str}, socket) do
|
def handle_info({:sort, field_str}, socket) do
|
||||||
|
|
@ -170,11 +200,12 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
existing_sort_query = socket.assigns.sort_order
|
existing_sort_query = socket.assigns.sort_order
|
||||||
|
|
||||||
# Build the URL with queries
|
# Build the URL with queries
|
||||||
query_params = %{
|
query_params =
|
||||||
"query" => q,
|
build_query_params(socket, %{
|
||||||
"sort_field" => existing_field_query,
|
"query" => q,
|
||||||
"sort_order" => existing_sort_query
|
"sort_field" => existing_field_query,
|
||||||
}
|
"sort_order" => existing_sort_query
|
||||||
|
})
|
||||||
|
|
||||||
# Set the new path with params
|
# Set the new path with params
|
||||||
new_path = ~p"/members?#{query_params}"
|
new_path = ~p"/members?#{query_params}"
|
||||||
|
|
@ -187,22 +218,109 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
)}
|
)}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_info({:field_toggled, field_string, visible}, socket) do
|
||||||
|
# Update user field selection
|
||||||
|
new_selection = Map.put(socket.assigns.user_field_selection, field_string, visible)
|
||||||
|
|
||||||
|
# Save to session (cookie will be saved on next page load via handle_params)
|
||||||
|
socket = update_session_field_selection(socket, new_selection)
|
||||||
|
|
||||||
|
# Merge with global settings
|
||||||
|
final_selection =
|
||||||
|
FieldVisibility.merge_with_global_settings(
|
||||||
|
new_selection,
|
||||||
|
socket.assigns.settings,
|
||||||
|
socket.assigns.custom_fields_visible
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get visible fields
|
||||||
|
visible_member_fields = FieldVisibility.get_visible_member_fields(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(:visible_custom_field_ids, extract_custom_field_ids(visible_custom_fields))
|
||||||
|
|> load_members(socket.assigns.query)
|
||||||
|
|> prepare_dynamic_cols()
|
||||||
|
|> push_field_selection_url()
|
||||||
|
|
||||||
|
{:noreply, socket}
|
||||||
|
end
|
||||||
|
|
||||||
|
@impl true
|
||||||
|
def handle_info({:fields_selected, selection}, socket) do
|
||||||
|
# Save to session
|
||||||
|
socket = update_session_field_selection(socket, selection)
|
||||||
|
|
||||||
|
# Merge with global settings (use all_custom_fields for merging)
|
||||||
|
final_selection =
|
||||||
|
FieldVisibility.merge_with_global_settings(
|
||||||
|
selection,
|
||||||
|
socket.assigns.settings,
|
||||||
|
socket.assigns.all_custom_fields
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get visible fields
|
||||||
|
visible_member_fields = FieldVisibility.get_visible_member_fields(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(:visible_custom_field_ids, extract_custom_field_ids(visible_custom_fields))
|
||||||
|
|> load_members(socket.assigns.query)
|
||||||
|
|> prepare_dynamic_cols()
|
||||||
|
|> push_field_selection_url()
|
||||||
|
|
||||||
|
{:noreply, socket}
|
||||||
|
end
|
||||||
|
|
||||||
# -----------------------------------------------------------------
|
# -----------------------------------------------------------------
|
||||||
# Handle Params from the URL
|
# Handle Params from the URL
|
||||||
# -----------------------------------------------------------------
|
# -----------------------------------------------------------------
|
||||||
@doc """
|
@doc """
|
||||||
Handles URL parameter changes.
|
Handles URL parameter changes.
|
||||||
|
|
||||||
Parses query parameters for search query, sort field, and sort order,
|
Parses query parameters for search query, sort field, sort order, and field selection,
|
||||||
then loads members accordingly. This enables bookmarkable URLs and
|
then loads members accordingly. This enables bookmarkable URLs and
|
||||||
browser back/forward navigation.
|
browser back/forward navigation.
|
||||||
"""
|
"""
|
||||||
@impl true
|
@impl true
|
||||||
def handle_params(params, _url, socket) do
|
def handle_params(params, _url, socket) do
|
||||||
|
# Parse field selection from URL
|
||||||
|
url_selection = FieldSelection.parse_from_url(params)
|
||||||
|
|
||||||
|
# Merge with session selection (URL has priority)
|
||||||
|
merged_selection =
|
||||||
|
FieldSelection.merge_sources(
|
||||||
|
url_selection,
|
||||||
|
socket.assigns.user_field_selection,
|
||||||
|
%{}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Merge with global settings (use all_custom_fields for merging)
|
||||||
|
final_selection =
|
||||||
|
FieldVisibility.merge_with_global_settings(
|
||||||
|
merged_selection,
|
||||||
|
socket.assigns.settings,
|
||||||
|
socket.assigns.all_custom_fields
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get visible fields
|
||||||
|
visible_member_fields = FieldVisibility.get_visible_member_fields(final_selection)
|
||||||
|
visible_custom_fields = FieldVisibility.get_visible_custom_fields(final_selection)
|
||||||
|
|
||||||
socket =
|
socket =
|
||||||
socket
|
socket
|
||||||
|> maybe_update_search(params)
|
|> maybe_update_search(params)
|
||||||
|> maybe_update_sort(params)
|
|> maybe_update_sort(params)
|
||||||
|
|> assign(:user_field_selection, final_selection)
|
||||||
|
|> assign(:member_fields_visible, visible_member_fields)
|
||||||
|
|> assign(:visible_custom_field_ids, extract_custom_field_ids(visible_custom_fields))
|
||||||
|> load_members(params["query"])
|
|> load_members(params["query"])
|
||||||
|> prepare_dynamic_cols()
|
|> prepare_dynamic_cols()
|
||||||
|
|
||||||
|
|
@ -215,10 +333,16 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
# - `:custom_field` - The CustomField resource
|
# - `:custom_field` - The CustomField resource
|
||||||
# - `:render` - A function that formats the custom field value for a given member
|
# - `:render` - A function that formats the custom field value for a given member
|
||||||
#
|
#
|
||||||
|
# Only includes custom fields that are visible according to user field selection.
|
||||||
|
#
|
||||||
# Returns the socket with `:dynamic_cols` assigned.
|
# Returns the socket with `:dynamic_cols` assigned.
|
||||||
defp prepare_dynamic_cols(socket) do
|
defp prepare_dynamic_cols(socket) do
|
||||||
|
visible_custom_field_ids = socket.assigns[:visible_custom_field_ids] || []
|
||||||
|
|
||||||
dynamic_cols =
|
dynamic_cols =
|
||||||
Enum.map(socket.assigns.custom_fields_visible, fn custom_field ->
|
socket.assigns.custom_fields_visible
|
||||||
|
|> Enum.filter(fn custom_field -> custom_field.id in visible_custom_field_ids end)
|
||||||
|
|> Enum.map(fn custom_field ->
|
||||||
%{
|
%{
|
||||||
custom_field: custom_field,
|
custom_field: custom_field,
|
||||||
render: fn member ->
|
render: fn member ->
|
||||||
|
|
@ -294,11 +418,11 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
field
|
field
|
||||||
end
|
end
|
||||||
|
|
||||||
query_params = %{
|
query_params =
|
||||||
"query" => socket.assigns.query,
|
build_query_params(socket, %{
|
||||||
"sort_field" => field_str,
|
"sort_field" => field_str,
|
||||||
"sort_order" => Atom.to_string(order)
|
"sort_order" => Atom.to_string(order)
|
||||||
}
|
})
|
||||||
|
|
||||||
new_path = ~p"/members?#{query_params}"
|
new_path = ~p"/members?#{query_params}"
|
||||||
|
|
||||||
|
|
@ -309,6 +433,47 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
)}
|
)}
|
||||||
end
|
end
|
||||||
|
|
||||||
|
# Builds query parameters including field selection
|
||||||
|
defp build_query_params(socket, base_params) do
|
||||||
|
base_params
|
||||||
|
|> Map.put("query", socket.assigns.query || "")
|
||||||
|
|> maybe_add_field_selection(socket.assigns[:user_field_selection])
|
||||||
|
end
|
||||||
|
|
||||||
|
# Adds field selection to query params if present
|
||||||
|
defp maybe_add_field_selection(params, nil), do: params
|
||||||
|
|
||||||
|
defp maybe_add_field_selection(params, selection) when is_map(selection) do
|
||||||
|
fields_param = FieldSelection.to_url_param(selection)
|
||||||
|
if fields_param != "", do: Map.put(params, "fields", fields_param), else: params
|
||||||
|
end
|
||||||
|
|
||||||
|
defp maybe_add_field_selection(params, _), do: params
|
||||||
|
|
||||||
|
# Pushes URL with updated field selection
|
||||||
|
defp push_field_selection_url(socket) do
|
||||||
|
query_params =
|
||||||
|
build_query_params(socket, %{
|
||||||
|
"sort_field" => field_to_string(socket.assigns.sort_field),
|
||||||
|
"sort_order" => Atom.to_string(socket.assigns.sort_order)
|
||||||
|
})
|
||||||
|
|
||||||
|
new_path = ~p"/members?#{query_params}"
|
||||||
|
|
||||||
|
push_patch(socket, to: new_path, replace: true)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Converts field to string
|
||||||
|
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
|
||||||
|
|
||||||
|
# Updates session field selection (stored in socket for now, actual session update via controller)
|
||||||
|
defp update_session_field_selection(socket, selection) do
|
||||||
|
# Store in socket for now - actual session persistence would require a controller
|
||||||
|
# This is a placeholder for future session persistence
|
||||||
|
assign(socket, :user_field_selection, selection)
|
||||||
|
end
|
||||||
|
|
||||||
# Loads members from the database with custom field values and applies search/sort filters.
|
# Loads members from the database with custom field values and applies search/sort filters.
|
||||||
#
|
#
|
||||||
# Process:
|
# Process:
|
||||||
|
|
@ -333,9 +498,9 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
|> Ash.Query.new()
|
|> Ash.Query.new()
|
||||||
|> Ash.Query.select(@overview_fields)
|
|> Ash.Query.select(@overview_fields)
|
||||||
|
|
||||||
# Load custom field values for visible custom fields
|
# Load custom field values for visible custom fields (based on user selection)
|
||||||
custom_field_ids_list = Enum.map(socket.assigns.custom_fields_visible, & &1.id)
|
visible_custom_field_ids = socket.assigns[:visible_custom_field_ids] || []
|
||||||
query = load_custom_field_values(query, custom_field_ids_list)
|
query = load_custom_field_values(query, visible_custom_field_ids)
|
||||||
|
|
||||||
# Apply the search filter first
|
# Apply the search filter first
|
||||||
query = apply_search_filter(query, search_query)
|
query = apply_search_filter(query, search_query)
|
||||||
|
|
@ -770,20 +935,6 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
# Gets the list of member fields that should be visible in the overview.
|
|
||||||
#
|
|
||||||
# Filters the member field configurations to return only fields with show_in_overview: true.
|
|
||||||
#
|
|
||||||
# Parameters:
|
|
||||||
# - `settings` - The settings struct loaded from the database
|
|
||||||
#
|
|
||||||
# Returns a list of atoms representing visible member field names.
|
|
||||||
@spec get_visible_member_fields(map()) :: [atom()]
|
|
||||||
defp get_visible_member_fields(settings) do
|
|
||||||
get_member_field_configurations(settings)
|
|
||||||
|> Enum.filter(fn {_field, show_in_overview} -> show_in_overview end)
|
|
||||||
|> Enum.map(fn {field, _show_in_overview} -> field end)
|
|
||||||
end
|
|
||||||
|
|
||||||
# Normalizes visibility config map keys from strings to atoms.
|
# Normalizes visibility config map keys from strings to atoms.
|
||||||
# JSONB in PostgreSQL converts atom keys to string keys when storing.
|
# JSONB in PostgreSQL converts atom keys to string keys when storing.
|
||||||
|
|
@ -808,4 +959,16 @@ defmodule MvWeb.MemberLive.Index do
|
||||||
end
|
end
|
||||||
|
|
||||||
defp normalize_visibility_config(_), do: %{}
|
defp normalize_visibility_config(_), do: %{}
|
||||||
|
|
||||||
|
# Extracts custom field IDs from visible custom field strings
|
||||||
|
# Format: "custom_field_<id>" -> <id>
|
||||||
|
defp extract_custom_field_ids(visible_custom_fields) do
|
||||||
|
Enum.map(visible_custom_fields, fn field_string ->
|
||||||
|
case String.split(field_string, "custom_field_") do
|
||||||
|
["", id] -> id
|
||||||
|
_ -> nil
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|> Enum.filter(&(&1 != nil))
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,13 @@
|
||||||
<.header>
|
<.header>
|
||||||
{gettext("Members")}
|
{gettext("Members")}
|
||||||
<:actions>
|
<:actions>
|
||||||
|
<.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}
|
||||||
|
/>
|
||||||
<.button variant="primary" navigate={~p"/members/new"}>
|
<.button variant="primary" navigate={~p"/members/new"}>
|
||||||
<.icon name="hero-plus" /> {gettext("New Member")}
|
<.icon name="hero-plus" /> {gettext("New Member")}
|
||||||
</.button>
|
</.button>
|
||||||
|
|
@ -54,6 +61,7 @@
|
||||||
</:col>
|
</:col>
|
||||||
<:col
|
<:col
|
||||||
:let={member}
|
:let={member}
|
||||||
|
:if={:first_name in @member_fields_visible}
|
||||||
label={
|
label={
|
||||||
~H"""
|
~H"""
|
||||||
<.live_component
|
<.live_component
|
||||||
|
|
@ -67,7 +75,25 @@
|
||||||
"""
|
"""
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{member.first_name} {member.last_name}
|
{member.first_name}
|
||||||
|
</:col>
|
||||||
|
<:col
|
||||||
|
:let={member}
|
||||||
|
:if={:last_name in @member_fields_visible}
|
||||||
|
label={
|
||||||
|
~H"""
|
||||||
|
<.live_component
|
||||||
|
module={MvWeb.Components.SortHeaderComponent}
|
||||||
|
id={:sort_last_name}
|
||||||
|
field={:last_name}
|
||||||
|
label={gettext("Last name")}
|
||||||
|
sort_field={@sort_field}
|
||||||
|
sort_order={@sort_order}
|
||||||
|
/>
|
||||||
|
"""
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{member.last_name}
|
||||||
</:col>
|
</:col>
|
||||||
<:col
|
<:col
|
||||||
:let={member}
|
:let={member}
|
||||||
|
|
|
||||||
232
lib/mv_web/live/member_live/index/field_selection.ex
Normal file
232
lib/mv_web/live/member_live/index/field_selection.ex
Normal file
|
|
@ -0,0 +1,232 @@
|
||||||
|
defmodule MvWeb.MemberLive.Index.FieldSelection do
|
||||||
|
@moduledoc """
|
||||||
|
Handles user-specific field selection persistence and URL parameter parsing.
|
||||||
|
|
||||||
|
This module manages:
|
||||||
|
- Reading/writing field selection from cookies (persistent storage)
|
||||||
|
- Reading/writing field selection from session (temporary storage)
|
||||||
|
- Parsing field selection from URL parameters
|
||||||
|
- Merging multiple sources with priority: URL > Session > Cookie
|
||||||
|
|
||||||
|
## Data Format
|
||||||
|
|
||||||
|
Field selection is stored as a map:
|
||||||
|
```elixir
|
||||||
|
%{
|
||||||
|
"first_name" => true,
|
||||||
|
"email" => true,
|
||||||
|
"street" => false,
|
||||||
|
"custom_field_abc-123" => true
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cookie/Session Format
|
||||||
|
|
||||||
|
Stored as JSON string: `{"first_name":true,"email":true}`
|
||||||
|
|
||||||
|
## URL Format
|
||||||
|
|
||||||
|
Comma-separated list: `?fields=first_name,email,custom_field_abc-123`
|
||||||
|
"""
|
||||||
|
|
||||||
|
@cookie_name "member_field_selection"
|
||||||
|
@cookie_max_age 365 * 24 * 60 * 60
|
||||||
|
@session_key "member_field_selection"
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Reads field selection from session.
|
||||||
|
|
||||||
|
Returns a map of field names (strings) to boolean visibility values.
|
||||||
|
Returns empty map if no selection is stored.
|
||||||
|
"""
|
||||||
|
@spec get_from_session(map()) :: %{String.t() => boolean()}
|
||||||
|
def get_from_session(session) when is_map(session) do
|
||||||
|
case Map.get(session, @session_key) do
|
||||||
|
nil -> %{}
|
||||||
|
json_string when is_binary(json_string) -> parse_json(json_string)
|
||||||
|
_ -> %{}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def get_from_session(_), do: %{}
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Saves field selection to session.
|
||||||
|
|
||||||
|
Converts the map to JSON string and stores it in the session.
|
||||||
|
"""
|
||||||
|
@spec save_to_session(map(), %{String.t() => boolean()}) :: map()
|
||||||
|
def save_to_session(session, selection) when is_map(selection) do
|
||||||
|
json_string = Jason.encode!(selection)
|
||||||
|
Map.put(session, @session_key, json_string)
|
||||||
|
end
|
||||||
|
|
||||||
|
def save_to_session(session, _), do: session
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Reads field selection from cookie.
|
||||||
|
|
||||||
|
Returns a map of field names (strings) to boolean visibility values.
|
||||||
|
Returns empty map if no cookie is present.
|
||||||
|
|
||||||
|
Note: This function requires the connection to have cookies parsed.
|
||||||
|
In LiveView, cookies are typically accessed via get_connect_info.
|
||||||
|
"""
|
||||||
|
@spec get_from_cookie(Plug.Conn.t()) :: %{String.t() => boolean()}
|
||||||
|
def get_from_cookie(conn) do
|
||||||
|
case Plug.Conn.get_req_header(conn, "cookie") do
|
||||||
|
nil ->
|
||||||
|
%{}
|
||||||
|
|
||||||
|
cookie_header ->
|
||||||
|
# Parse cookies manually from header
|
||||||
|
cookies = parse_cookie_header(cookie_header)
|
||||||
|
|
||||||
|
case Map.get(cookies, @cookie_name) do
|
||||||
|
nil -> %{}
|
||||||
|
json_string when is_binary(json_string) -> parse_json(json_string)
|
||||||
|
_ -> %{}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Parses cookie header string into a 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
|
||||||
|
|
||||||
|
defp parse_cookie_header(_), do: %{}
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Saves field selection to cookie.
|
||||||
|
|
||||||
|
Sets a persistent cookie with the field selection as JSON.
|
||||||
|
"""
|
||||||
|
@spec save_to_cookie(Plug.Conn.t(), %{String.t() => boolean()}) :: Plug.Conn.t()
|
||||||
|
def save_to_cookie(conn, selection) when is_map(selection) do
|
||||||
|
json_string = Jason.encode!(selection)
|
||||||
|
secure = Application.get_env(:mv, :use_secure_cookies, false)
|
||||||
|
|
||||||
|
Plug.Conn.put_resp_cookie(conn, @cookie_name, json_string,
|
||||||
|
max_age: @cookie_max_age,
|
||||||
|
same_site: "Lax",
|
||||||
|
http_only: true,
|
||||||
|
secure: secure
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def save_to_cookie(conn, _), do: conn
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Parses field selection from URL parameters.
|
||||||
|
|
||||||
|
Expects a comma-separated list of field names in the `fields` parameter.
|
||||||
|
All fields in the list are set to `true` (visible).
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
iex> parse_from_url(%{"fields" => "first_name,email"})
|
||||||
|
%{"first_name" => true, "email" => true}
|
||||||
|
|
||||||
|
iex> parse_from_url(%{"fields" => "custom_field_abc-123"})
|
||||||
|
%{"custom_field_abc-123" => true}
|
||||||
|
|
||||||
|
iex> parse_from_url(%{})
|
||||||
|
%{}
|
||||||
|
"""
|
||||||
|
@spec parse_from_url(map()) :: %{String.t() => boolean()}
|
||||||
|
def parse_from_url(params) when is_map(params) do
|
||||||
|
case Map.get(params, "fields") do
|
||||||
|
nil -> %{}
|
||||||
|
"" -> %{}
|
||||||
|
fields_string when is_binary(fields_string) -> parse_fields_string(fields_string)
|
||||||
|
_ -> %{}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
def parse_from_url(_), do: %{}
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Merges multiple field selection sources with priority.
|
||||||
|
|
||||||
|
Priority order (highest to lowest):
|
||||||
|
1. URL parameters
|
||||||
|
2. Session
|
||||||
|
3. Cookie
|
||||||
|
|
||||||
|
Later sources override earlier ones for the same field.
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
iex> merge_sources(%{"first_name" => true}, %{"email" => true}, %{"street" => true})
|
||||||
|
%{"first_name" => true, "email" => true, "street" => true}
|
||||||
|
|
||||||
|
iex> merge_sources(%{"first_name" => false}, %{"first_name" => true}, %{})
|
||||||
|
%{"first_name" => false} # URL has priority
|
||||||
|
"""
|
||||||
|
@spec merge_sources(
|
||||||
|
%{String.t() => boolean()},
|
||||||
|
%{String.t() => boolean()},
|
||||||
|
%{String.t() => boolean()}
|
||||||
|
) :: %{String.t() => boolean()}
|
||||||
|
def merge_sources(url_selection, session_selection, cookie_selection) do
|
||||||
|
%{}
|
||||||
|
|> Map.merge(cookie_selection)
|
||||||
|
|> Map.merge(session_selection)
|
||||||
|
|> Map.merge(url_selection)
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Converts field selection map to URL parameter string.
|
||||||
|
|
||||||
|
Returns a comma-separated string of visible fields (where value is `true`).
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
iex> to_url_param(%{"first_name" => true, "email" => true, "street" => false})
|
||||||
|
"first_name,email"
|
||||||
|
"""
|
||||||
|
@spec to_url_param(%{String.t() => boolean()}) :: String.t()
|
||||||
|
def to_url_param(selection) when is_map(selection) do
|
||||||
|
selection
|
||||||
|
|> Enum.filter(fn {_field, visible} -> visible end)
|
||||||
|
|> Enum.map(fn {field, _visible} -> field end)
|
||||||
|
|> Enum.join(",")
|
||||||
|
end
|
||||||
|
|
||||||
|
def to_url_param(_), do: ""
|
||||||
|
|
||||||
|
# Parses a JSON string into a map, handling errors gracefully
|
||||||
|
defp parse_json(json_string) when is_binary(json_string) do
|
||||||
|
case Jason.decode(json_string) do
|
||||||
|
{:ok, decoded} when is_map(decoded) ->
|
||||||
|
# Ensure all values are booleans
|
||||||
|
Enum.reduce(decoded, %{}, fn
|
||||||
|
{key, value} when is_boolean(value) -> {key, value}
|
||||||
|
{key, _value} -> {key, true}
|
||||||
|
end)
|
||||||
|
|
||||||
|
_ ->
|
||||||
|
%{}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
defp parse_json(_), do: %{}
|
||||||
|
|
||||||
|
# Parses a comma-separated string of field names
|
||||||
|
defp parse_fields_string(fields_string) do
|
||||||
|
fields_string
|
||||||
|
|> String.split(",")
|
||||||
|
|> Enum.map(&String.trim/1)
|
||||||
|
|> Enum.filter(&(&1 != ""))
|
||||||
|
|> Enum.reduce(%{}, fn field, acc -> Map.put(acc, field, true) end)
|
||||||
|
end
|
||||||
|
end
|
||||||
235
lib/mv_web/live/member_live/index/field_visibility.ex
Normal file
235
lib/mv_web/live/member_live/index/field_visibility.ex
Normal file
|
|
@ -0,0 +1,235 @@
|
||||||
|
defmodule MvWeb.MemberLive.Index.FieldVisibility do
|
||||||
|
@moduledoc """
|
||||||
|
Manages field visibility by merging user-specific selection with global settings.
|
||||||
|
|
||||||
|
This module handles:
|
||||||
|
- Getting all available fields (member fields + custom fields)
|
||||||
|
- Merging user selection with global settings (user selection takes priority)
|
||||||
|
- Falling back to global settings when no user selection exists
|
||||||
|
- Converting between different field name formats (atoms vs strings)
|
||||||
|
|
||||||
|
## Field Naming Convention
|
||||||
|
|
||||||
|
- **Member Fields**: Atoms (e.g., `:first_name`, `:email`)
|
||||||
|
- **Custom Fields**: Strings with format `"custom_field_<id>"` (e.g., `"custom_field_abc-123"`)
|
||||||
|
|
||||||
|
## Priority Order
|
||||||
|
|
||||||
|
1. User-specific selection (from URL/Session/Cookie)
|
||||||
|
2. Global settings (from database)
|
||||||
|
3. Default (all fields visible)
|
||||||
|
"""
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Gets all available fields for selection.
|
||||||
|
|
||||||
|
Returns a list of field identifiers:
|
||||||
|
- Member fields as atoms (e.g., `:first_name`, `:email`)
|
||||||
|
- Custom fields as strings (e.g., `"custom_field_abc-123"`)
|
||||||
|
|
||||||
|
## Parameters
|
||||||
|
|
||||||
|
- `custom_fields` - List of CustomField resources that are available
|
||||||
|
|
||||||
|
## Returns
|
||||||
|
|
||||||
|
List of field identifiers (atoms and strings)
|
||||||
|
"""
|
||||||
|
@spec get_all_available_fields([struct()]) :: [atom() | String.t()]
|
||||||
|
def get_all_available_fields(custom_fields) do
|
||||||
|
member_fields = Mv.Constants.member_fields()
|
||||||
|
custom_field_names = Enum.map(custom_fields, &"custom_field_#{&1.id}")
|
||||||
|
|
||||||
|
member_fields ++ custom_field_names
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Merges user field selection with global settings.
|
||||||
|
|
||||||
|
User selection takes priority over global settings. If a field is not in the
|
||||||
|
user selection, the global setting is used. If a field is not in global settings,
|
||||||
|
it defaults to `true` (visible).
|
||||||
|
|
||||||
|
## Parameters
|
||||||
|
|
||||||
|
- `user_selection` - Map of field names (strings) to boolean visibility
|
||||||
|
- `global_settings` - Settings struct with `member_field_visibility` field
|
||||||
|
- `custom_fields` - List of CustomField resources
|
||||||
|
|
||||||
|
## Returns
|
||||||
|
|
||||||
|
Map of field names (strings) to boolean visibility values
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
iex> user_selection = %{"first_name" => false}
|
||||||
|
iex> settings = %{member_field_visibility: %{first_name: true, email: true}}
|
||||||
|
iex> merge_with_global_settings(user_selection, settings, [])
|
||||||
|
%{"first_name" => false, "email" => true} # User selection overrides global
|
||||||
|
"""
|
||||||
|
@spec merge_with_global_settings(
|
||||||
|
%{String.t() => boolean()},
|
||||||
|
map(),
|
||||||
|
[struct()]
|
||||||
|
) :: %{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)
|
||||||
|
|
||||||
|
Enum.reduce(all_fields, %{}, fn field, acc ->
|
||||||
|
field_string = field_to_string(field)
|
||||||
|
|
||||||
|
visibility =
|
||||||
|
case Map.get(user_selection, field_string) do
|
||||||
|
nil -> Map.get(global_visibility, field_string, true)
|
||||||
|
user_value -> user_value
|
||||||
|
end
|
||||||
|
|
||||||
|
Map.put(acc, field_string, visibility)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Gets the list of visible fields from a field selection map.
|
||||||
|
|
||||||
|
Returns only fields where visibility is `true`.
|
||||||
|
|
||||||
|
## Parameters
|
||||||
|
|
||||||
|
- `field_selection` - Map of field names to boolean visibility
|
||||||
|
|
||||||
|
## Returns
|
||||||
|
|
||||||
|
List of field identifiers (atoms for member fields, strings for custom fields)
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
iex> selection = %{"first_name" => true, "email" => false, "street" => true}
|
||||||
|
iex> get_visible_fields(selection)
|
||||||
|
[:first_name, :street]
|
||||||
|
"""
|
||||||
|
@spec get_visible_fields(%{String.t() => boolean()}) :: [atom() | String.t()]
|
||||||
|
def get_visible_fields(field_selection) when is_map(field_selection) do
|
||||||
|
field_selection
|
||||||
|
|> Enum.filter(fn {_field, visible} -> visible end)
|
||||||
|
|> Enum.map(fn {field_string, _visible} -> to_field_identifier(field_string) end)
|
||||||
|
end
|
||||||
|
|
||||||
|
def get_visible_fields(_), do: []
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Gets visible member fields from field selection.
|
||||||
|
|
||||||
|
Returns only member fields (atoms) that are visible.
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
iex> selection = %{"first_name" => true, "email" => true, "custom_field_123" => true}
|
||||||
|
iex> get_visible_member_fields(selection)
|
||||||
|
[:first_name, :email]
|
||||||
|
"""
|
||||||
|
@spec get_visible_member_fields(%{String.t() => boolean()}) :: [atom()]
|
||||||
|
def get_visible_member_fields(field_selection) when is_map(field_selection) do
|
||||||
|
member_fields = Mv.Constants.member_fields()
|
||||||
|
|
||||||
|
field_selection
|
||||||
|
|> Enum.filter(fn {field_string, visible} ->
|
||||||
|
field_atom = to_field_identifier(field_string)
|
||||||
|
visible && field_atom in member_fields
|
||||||
|
end)
|
||||||
|
|> Enum.map(fn {field_string, _visible} -> to_field_identifier(field_string) end)
|
||||||
|
end
|
||||||
|
|
||||||
|
def get_visible_member_fields(_), do: []
|
||||||
|
|
||||||
|
@doc """
|
||||||
|
Gets visible custom fields from field selection.
|
||||||
|
|
||||||
|
Returns only custom field identifiers (strings) that are visible.
|
||||||
|
|
||||||
|
## Examples
|
||||||
|
|
||||||
|
iex> selection = %{"first_name" => true, "custom_field_123" => true, "custom_field_456" => false}
|
||||||
|
iex> get_visible_custom_fields(selection)
|
||||||
|
["custom_field_123"]
|
||||||
|
"""
|
||||||
|
@spec get_visible_custom_fields(%{String.t() => boolean()}) :: [String.t()]
|
||||||
|
def get_visible_custom_fields(field_selection) when is_map(field_selection) do
|
||||||
|
field_selection
|
||||||
|
|> Enum.filter(fn {field_string, visible} ->
|
||||||
|
visible && String.starts_with?(field_string, "custom_field_")
|
||||||
|
end)
|
||||||
|
|> Enum.map(fn {field_string, _visible} -> field_string end)
|
||||||
|
end
|
||||||
|
|
||||||
|
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)
|
||||||
|
custom_field_visibility = get_custom_field_visibility(custom_fields)
|
||||||
|
|
||||||
|
Map.merge(member_visibility, custom_field_visibility)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Gets member field visibility from settings
|
||||||
|
defp get_member_field_visibility_from_settings(settings) do
|
||||||
|
visibility_config =
|
||||||
|
normalize_visibility_config(Map.get(settings, :member_field_visibility, %{}))
|
||||||
|
|
||||||
|
member_fields = Mv.Constants.member_fields()
|
||||||
|
|
||||||
|
Enum.reduce(member_fields, %{}, fn field, acc ->
|
||||||
|
field_string = Atom.to_string(field)
|
||||||
|
show_in_overview = Map.get(visibility_config, field, true)
|
||||||
|
Map.put(acc, field_string, show_in_overview)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Gets custom field visibility (all custom fields with show_in_overview=true are visible)
|
||||||
|
defp get_custom_field_visibility(custom_fields) do
|
||||||
|
Enum.reduce(custom_fields, %{}, fn custom_field, acc ->
|
||||||
|
field_string = "custom_field_#{custom_field.id}"
|
||||||
|
visible = Map.get(custom_field, :show_in_overview, true)
|
||||||
|
Map.put(acc, field_string, visible)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
# Normalizes visibility config map keys from strings to atoms
|
||||||
|
defp normalize_visibility_config(config) when is_map(config) do
|
||||||
|
Enum.reduce(config, %{}, fn
|
||||||
|
{key, value}, acc when is_atom(key) ->
|
||||||
|
Map.put(acc, key, value)
|
||||||
|
|
||||||
|
{key, value}, acc when is_binary(key) ->
|
||||||
|
try do
|
||||||
|
atom_key = String.to_existing_atom(key)
|
||||||
|
Map.put(acc, atom_key, value)
|
||||||
|
rescue
|
||||||
|
ArgumentError -> acc
|
||||||
|
end
|
||||||
|
|
||||||
|
_, acc ->
|
||||||
|
acc
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
defp normalize_visibility_config(_), do: %{}
|
||||||
|
|
||||||
|
# Converts field string to atom (for member fields) or keeps as string (for custom fields)
|
||||||
|
defp to_field_identifier(field_string) when is_binary(field_string) do
|
||||||
|
if String.starts_with?(field_string, "custom_field_") do
|
||||||
|
field_string
|
||||||
|
else
|
||||||
|
try do
|
||||||
|
String.to_existing_atom(field_string)
|
||||||
|
rescue
|
||||||
|
ArgumentError -> field_string
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# Converts field identifier to string
|
||||||
|
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
|
||||||
|
end
|
||||||
Loading…
Add table
Add a link
Reference in a new issue