Compare commits
9 commits
f62d1fbf51
...
206e733511
| Author | SHA1 | Date | |
|---|---|---|---|
| 206e733511 | |||
| 0fb43a0816 | |||
| 45a9bc0cc0 | |||
| d039e4bb7d | |||
| 7f0da693ee | |||
| 82e41916d2 | |||
| a022d8cd02 | |||
| f24d4985fc | |||
| cf957563bb |
19 changed files with 3228 additions and 73 deletions
|
|
@ -42,6 +42,10 @@ defmodule Mv.Membership.Member do
|
|||
@member_search_limit 10
|
||||
@default_similarity_threshold 0.2
|
||||
|
||||
# Use constants from Mv.Constants for member fields
|
||||
# This ensures consistency across the codebase
|
||||
@member_fields Mv.Constants.member_fields()
|
||||
|
||||
postgres do
|
||||
table "members"
|
||||
repo Mv.Repo
|
||||
|
|
@ -58,21 +62,7 @@ defmodule Mv.Membership.Member do
|
|||
# user_id is NOT in accept list to prevent direct foreign key manipulation
|
||||
argument :user, :map, allow_nil?: true
|
||||
|
||||
accept [
|
||||
:first_name,
|
||||
:last_name,
|
||||
:email,
|
||||
:birth_date,
|
||||
:paid,
|
||||
:phone_number,
|
||||
:join_date,
|
||||
:exit_date,
|
||||
:notes,
|
||||
:city,
|
||||
:street,
|
||||
:house_number,
|
||||
:postal_code
|
||||
]
|
||||
accept @member_fields
|
||||
|
||||
change manage_relationship(:custom_field_values, type: :create)
|
||||
|
||||
|
|
@ -105,21 +95,7 @@ defmodule Mv.Membership.Member do
|
|||
# user_id is NOT in accept list to prevent direct foreign key manipulation
|
||||
argument :user, :map, allow_nil?: true
|
||||
|
||||
accept [
|
||||
:first_name,
|
||||
:last_name,
|
||||
:email,
|
||||
:birth_date,
|
||||
:paid,
|
||||
:phone_number,
|
||||
:join_date,
|
||||
:exit_date,
|
||||
:notes,
|
||||
:city,
|
||||
:street,
|
||||
:house_number,
|
||||
:postal_code
|
||||
]
|
||||
accept @member_fields
|
||||
|
||||
change manage_relationship(:custom_field_values, on_match: :update, on_no_match: :create)
|
||||
|
||||
|
|
@ -434,6 +410,70 @@ defmodule Mv.Membership.Member do
|
|||
identity :unique_email, [:email]
|
||||
end
|
||||
|
||||
@doc """
|
||||
Checks if a member field should be shown in the overview.
|
||||
|
||||
Reads the visibility configuration from Settings resource. If a field is not
|
||||
configured in settings, it defaults to `true` (visible).
|
||||
|
||||
## Parameters
|
||||
- `field` - Atom representing the member field name (e.g., `:email`, `:street`)
|
||||
|
||||
## Returns
|
||||
- `true` if the field should be shown in overview (default)
|
||||
- `false` if the field is configured as hidden in settings
|
||||
|
||||
## Examples
|
||||
|
||||
iex> Member.show_in_overview?(:email)
|
||||
true
|
||||
|
||||
iex> Member.show_in_overview?(:street)
|
||||
true # or false if configured in settings
|
||||
|
||||
"""
|
||||
@spec show_in_overview?(atom()) :: boolean()
|
||||
def show_in_overview?(field) when is_atom(field) do
|
||||
case Mv.Membership.get_settings() do
|
||||
{:ok, settings} ->
|
||||
visibility_config = settings.member_field_visibility || %{}
|
||||
# Normalize map keys to atoms (JSONB may return string keys)
|
||||
normalized_config = normalize_visibility_config(visibility_config)
|
||||
|
||||
# Get value from normalized config, default to true
|
||||
Map.get(normalized_config, field, true)
|
||||
|
||||
{:error, _} ->
|
||||
# If settings can't be loaded, default to visible
|
||||
true
|
||||
end
|
||||
end
|
||||
|
||||
def show_in_overview?(_), do: true
|
||||
|
||||
# Normalizes visibility config map keys from strings to atoms.
|
||||
# JSONB in PostgreSQL converts atom keys to string keys when storing.
|
||||
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: %{}
|
||||
|
||||
@doc """
|
||||
Performs fuzzy search on members using PostgreSQL trigram similarity.
|
||||
|
||||
|
|
|
|||
|
|
@ -53,6 +53,7 @@ defmodule Mv.Membership do
|
|||
# It's only used internally as fallback in get_settings/0
|
||||
# Settings should be created via seed script
|
||||
define :update_settings, action: :update
|
||||
define :update_member_field_visibility, action: :update_member_field_visibility
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -123,4 +124,37 @@ defmodule Mv.Membership do
|
|||
|> Ash.Changeset.for_update(:update, attrs)
|
||||
|> Ash.update(domain: __MODULE__)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Updates the member field visibility configuration.
|
||||
|
||||
This is a specialized action for updating only the member field visibility settings.
|
||||
It validates that all keys are valid member fields and all values are booleans.
|
||||
|
||||
## Parameters
|
||||
|
||||
- `settings` - The settings record to update
|
||||
- `visibility_config` - A map of member field names (atoms) to boolean visibility values
|
||||
(e.g., `%{street: false, house_number: false}`)
|
||||
|
||||
## Returns
|
||||
|
||||
- `{:ok, updated_settings}` - Successfully updated settings
|
||||
- `{:error, error}` - Validation or update error
|
||||
|
||||
## Examples
|
||||
|
||||
iex> {:ok, settings} = Mv.Membership.get_settings()
|
||||
iex> {:ok, updated} = Mv.Membership.update_member_field_visibility(settings, %{street: false, house_number: false})
|
||||
iex> updated.member_field_visibility
|
||||
%{street: false, house_number: false}
|
||||
|
||||
"""
|
||||
def update_member_field_visibility(settings, visibility_config) do
|
||||
settings
|
||||
|> Ash.Changeset.for_update(:update_member_field_visibility, %{
|
||||
member_field_visibility: visibility_config
|
||||
})
|
||||
|> Ash.update(domain: __MODULE__)
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@ defmodule Mv.Membership.Setting do
|
|||
|
||||
## Attributes
|
||||
- `club_name` - The name of the association/club (required, cannot be empty)
|
||||
- `member_field_visibility` - JSONB map storing visibility configuration for member fields
|
||||
(e.g., `%{street: false, house_number: false}`). Fields not in the map default to `true`.
|
||||
|
||||
## Singleton Pattern
|
||||
This resource uses a singleton pattern - there should only be one settings record.
|
||||
|
|
@ -28,6 +30,9 @@ defmodule Mv.Membership.Setting do
|
|||
|
||||
# Update club name
|
||||
{:ok, updated} = Mv.Membership.update_settings(settings, %{club_name: "New Name"})
|
||||
|
||||
# Update member field visibility
|
||||
{:ok, updated} = Mv.Membership.update_member_field_visibility(settings, %{street: false, house_number: false})
|
||||
"""
|
||||
use Ash.Resource,
|
||||
domain: Mv.Membership,
|
||||
|
|
@ -49,18 +54,86 @@ defmodule Mv.Membership.Setting do
|
|||
# Used only as fallback in get_settings/0 if settings don't exist
|
||||
# Settings should normally be created via seed script
|
||||
create :create do
|
||||
accept [:club_name]
|
||||
accept [:club_name, :member_field_visibility]
|
||||
end
|
||||
|
||||
update :update do
|
||||
primary? true
|
||||
accept [:club_name]
|
||||
require_atomic? false
|
||||
accept [:club_name, :member_field_visibility]
|
||||
end
|
||||
|
||||
update :update_member_field_visibility do
|
||||
description "Updates the visibility configuration for member fields in the overview"
|
||||
require_atomic? false
|
||||
accept [:member_field_visibility]
|
||||
|
||||
change fn changeset, _context ->
|
||||
visibility = Ash.Changeset.get_attribute(changeset, :member_field_visibility)
|
||||
|
||||
if visibility && is_map(visibility) do
|
||||
valid_fields = Mv.Constants.member_fields()
|
||||
# Normalize keys to atoms (JSONB may return string keys)
|
||||
invalid_keys =
|
||||
Enum.filter(visibility, fn {key, _value} ->
|
||||
atom_key =
|
||||
if is_atom(key) do
|
||||
key
|
||||
else
|
||||
try do
|
||||
String.to_existing_atom(key)
|
||||
rescue
|
||||
ArgumentError -> nil
|
||||
end
|
||||
end
|
||||
|
||||
atom_key && atom_key not in valid_fields
|
||||
end)
|
||||
|> Enum.map(fn {key, _value} -> key end)
|
||||
|
||||
if Enum.empty?(invalid_keys) do
|
||||
changeset
|
||||
else
|
||||
Ash.Changeset.add_error(
|
||||
changeset,
|
||||
field: :member_field_visibility,
|
||||
message: "Invalid member field keys: #{inspect(invalid_keys)}"
|
||||
)
|
||||
end
|
||||
else
|
||||
changeset
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
validations do
|
||||
validate present(:club_name), on: [:create, :update]
|
||||
validate string_length(:club_name, min: 1), on: [:create, :update]
|
||||
|
||||
# Validate that member_field_visibility map contains only boolean values
|
||||
# This allows dynamic fields without hardcoding specific field names
|
||||
validate fn changeset, _context ->
|
||||
visibility = Ash.Changeset.get_attribute(changeset, :member_field_visibility)
|
||||
|
||||
if visibility && is_map(visibility) do
|
||||
invalid_entries =
|
||||
Enum.filter(visibility, fn {_key, value} ->
|
||||
not is_boolean(value)
|
||||
end)
|
||||
|
||||
if Enum.empty?(invalid_entries) do
|
||||
:ok
|
||||
else
|
||||
{:error,
|
||||
field: :member_field_visibility,
|
||||
message: "All values in member_field_visibility must be booleans"}
|
||||
end
|
||||
else
|
||||
:ok
|
||||
end
|
||||
end,
|
||||
on: [:create, :update]
|
||||
end
|
||||
|
||||
attributes do
|
||||
|
|
@ -75,6 +148,12 @@ defmodule Mv.Membership.Setting do
|
|||
min_length: 1
|
||||
]
|
||||
|
||||
attribute :member_field_visibility, :map,
|
||||
allow_nil?: true,
|
||||
public?: true,
|
||||
description:
|
||||
"Configuration for member field visibility in overview (JSONB map). Keys are member field names (atoms), values are booleans."
|
||||
|
||||
timestamps()
|
||||
end
|
||||
end
|
||||
|
|
|
|||
23
lib/mv/constants.ex
Normal file
23
lib/mv/constants.ex
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
defmodule Mv.Constants do
|
||||
@moduledoc """
|
||||
Module for defining constants and atoms.
|
||||
"""
|
||||
|
||||
@member_fields [
|
||||
:first_name,
|
||||
:last_name,
|
||||
:email,
|
||||
:birth_date,
|
||||
:paid,
|
||||
:phone_number,
|
||||
:join_date,
|
||||
:exit_date,
|
||||
:notes,
|
||||
:city,
|
||||
:street,
|
||||
:house_number,
|
||||
:postal_code
|
||||
]
|
||||
|
||||
def member_fields, do: @member_fields
|
||||
end
|
||||
|
|
@ -111,6 +111,126 @@ defmodule MvWeb.CoreComponents do
|
|||
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 """
|
||||
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
|
||||
|
|
@ -29,11 +29,20 @@ defmodule MvWeb.MemberLive.Index do
|
|||
require Ash.Query
|
||||
import Ash.Expr
|
||||
|
||||
alias Mv.Membership
|
||||
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>")
|
||||
@custom_field_prefix "custom_field_"
|
||||
|
||||
# Member fields that are loaded for the overview
|
||||
# Uses constants from Mv.Constants to ensure consistency
|
||||
# Note: :id is always included for member identification
|
||||
# All member fields are loaded, but visibility is controlled via settings
|
||||
@overview_fields [:id | Mv.Constants.member_fields()]
|
||||
|
||||
@doc """
|
||||
Initializes the LiveView state.
|
||||
|
||||
|
|
@ -41,8 +50,8 @@ defmodule MvWeb.MemberLive.Index do
|
|||
and member selection. Actual data loading happens in `handle_params/3`.
|
||||
"""
|
||||
@impl true
|
||||
def mount(_params, _session, socket) do
|
||||
# Load custom fields that should be shown in overview
|
||||
def mount(_params, session, socket) do
|
||||
# Load custom fields that should be shown in overview (for display)
|
||||
# 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
|
||||
# should be visible to the user rather than silently failing.
|
||||
|
|
@ -52,6 +61,34 @@ defmodule MvWeb.MemberLive.Index do
|
|||
|> Ash.Query.sort(name: :asc)
|
||||
|> 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
|
||||
settings =
|
||||
case Membership.get_settings() do
|
||||
{:ok, s} -> s
|
||||
# Fallback if settings can't be loaded
|
||||
{:error, _} -> %{member_field_visibility: %{}}
|
||||
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
|
||||
|> assign(:page_title, gettext("Members"))
|
||||
|
|
@ -59,7 +96,16 @@ defmodule MvWeb.MemberLive.Index do
|
|||
|> assign_new(:sort_field, fn -> :first_name end)
|
||||
|> assign_new(:sort_order, fn -> :asc end)
|
||||
|> assign(:selected_members, [])
|
||||
|> assign(:settings, settings)
|
||||
|> 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_fields_visible,
|
||||
FieldVisibility.get_visible_member_fields(initial_selection)
|
||||
)
|
||||
|
||||
# We call handle params to use the query from the URL
|
||||
{:ok, socket}
|
||||
|
|
@ -126,6 +172,8 @@ defmodule MvWeb.MemberLive.Index do
|
|||
## Supported messages:
|
||||
- `{: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
|
||||
- `{:field_toggled, field, visible}` - Field toggle event from FieldVisibilityDropdownComponent
|
||||
- `{:fields_selected, selection}` - Select all/deselect all event from FieldVisibilityDropdownComponent
|
||||
"""
|
||||
@impl true
|
||||
def handle_info({:sort, field_str}, socket) do
|
||||
|
|
@ -146,17 +194,22 @@ defmodule MvWeb.MemberLive.Index do
|
|||
|
||||
@impl true
|
||||
def handle_info({:search_changed, q}, socket) do
|
||||
# Update query assign first
|
||||
socket = assign(socket, :query, q)
|
||||
|
||||
# Load members with the new query
|
||||
socket = load_members(socket, q)
|
||||
|
||||
existing_field_query = socket.assigns.sort_field
|
||||
existing_sort_query = socket.assigns.sort_order
|
||||
|
||||
# Build the URL with queries
|
||||
query_params = %{
|
||||
query_params =
|
||||
build_query_params(socket, %{
|
||||
"query" => q,
|
||||
"sort_field" => existing_field_query,
|
||||
"sort_order" => existing_sort_query
|
||||
}
|
||||
})
|
||||
|
||||
# Set the new path with params
|
||||
new_path = ~p"/members?#{query_params}"
|
||||
|
|
@ -169,22 +222,109 @@ defmodule MvWeb.MemberLive.Index do
|
|||
)}
|
||||
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
|
||||
# -----------------------------------------------------------------
|
||||
@doc """
|
||||
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
|
||||
browser back/forward navigation.
|
||||
"""
|
||||
@impl true
|
||||
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
|
||||
|> maybe_update_search(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"])
|
||||
|> prepare_dynamic_cols()
|
||||
|
||||
|
|
@ -197,10 +337,16 @@ defmodule MvWeb.MemberLive.Index do
|
|||
# - `:custom_field` - The CustomField resource
|
||||
# - `: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.
|
||||
defp prepare_dynamic_cols(socket) do
|
||||
visible_custom_field_ids = socket.assigns[:visible_custom_field_ids] || []
|
||||
|
||||
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,
|
||||
render: fn member ->
|
||||
|
|
@ -276,11 +422,11 @@ defmodule MvWeb.MemberLive.Index do
|
|||
field
|
||||
end
|
||||
|
||||
query_params = %{
|
||||
"query" => socket.assigns.query,
|
||||
query_params =
|
||||
build_query_params(socket, %{
|
||||
"sort_field" => field_str,
|
||||
"sort_order" => Atom.to_string(order)
|
||||
}
|
||||
})
|
||||
|
||||
new_path = ~p"/members?#{query_params}"
|
||||
|
||||
|
|
@ -291,6 +437,50 @@ defmodule MvWeb.MemberLive.Index do
|
|||
)}
|
||||
end
|
||||
|
||||
# Builds query parameters including field selection
|
||||
defp build_query_params(socket, base_params) do
|
||||
# Use query from base_params if provided, otherwise fall back to socket.assigns.query
|
||||
query_value = Map.get(base_params, "query") || socket.assigns.query || ""
|
||||
|
||||
base_params
|
||||
|> Map.put("query", query_value)
|
||||
|> 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.
|
||||
#
|
||||
# Process:
|
||||
|
|
@ -313,22 +503,11 @@ defmodule MvWeb.MemberLive.Index do
|
|||
query =
|
||||
Mv.Membership.Member
|
||||
|> Ash.Query.new()
|
||||
|> Ash.Query.select([
|
||||
:id,
|
||||
:first_name,
|
||||
:last_name,
|
||||
:email,
|
||||
:street,
|
||||
:house_number,
|
||||
:postal_code,
|
||||
:city,
|
||||
:phone_number,
|
||||
:join_date
|
||||
])
|
||||
|> Ash.Query.select(@overview_fields)
|
||||
|
||||
# Load custom field values for visible custom fields
|
||||
custom_field_ids_list = Enum.map(socket.assigns.custom_fields_visible, & &1.id)
|
||||
query = load_custom_field_values(query, custom_field_ids_list)
|
||||
# Load custom field values for visible custom fields (based on user selection)
|
||||
visible_custom_field_ids = socket.assigns[:visible_custom_field_ids] || []
|
||||
query = load_custom_field_values(query, visible_custom_field_ids)
|
||||
|
||||
# Apply the search filter first
|
||||
query = apply_search_filter(query, search_query)
|
||||
|
|
@ -433,18 +612,13 @@ defmodule MvWeb.MemberLive.Index do
|
|||
defp maybe_sort(query, _, _, _), do: {query, false}
|
||||
|
||||
# Validate that a field is sortable
|
||||
# Uses member fields from constants, but excludes fields that don't make sense to sort
|
||||
# (e.g., :notes is too long, :paid is boolean and not very useful for sorting)
|
||||
defp valid_sort_field?(field) when is_atom(field) do
|
||||
valid_fields = [
|
||||
:first_name,
|
||||
:last_name,
|
||||
:email,
|
||||
:street,
|
||||
:house_number,
|
||||
:postal_code,
|
||||
:city,
|
||||
:phone_number,
|
||||
:join_date
|
||||
]
|
||||
# All member fields are sortable, but we exclude some that don't make sense
|
||||
# :id is not in member_fields, but we don't want to sort by it anyway
|
||||
non_sortable_fields = [:notes, :paid]
|
||||
valid_fields = Mv.Constants.member_fields() -- non_sortable_fields
|
||||
|
||||
field in valid_fields or custom_field_sort?(field)
|
||||
end
|
||||
|
|
@ -733,4 +907,75 @@ defmodule MvWeb.MemberLive.Index do
|
|||
nil
|
||||
end
|
||||
end
|
||||
|
||||
# Gets the configuration for all member fields with their show_in_overview values.
|
||||
#
|
||||
# Reads the visibility configuration from Settings and returns a map with all member fields
|
||||
# and their show_in_overview values (true or false). Fields not configured in settings
|
||||
# default to true.
|
||||
#
|
||||
# Performance: This function uses the already-loaded settings to avoid N+1 queries.
|
||||
# Settings should be loaded once in mount/3 and passed to this function.
|
||||
#
|
||||
# Parameters:
|
||||
# - `settings` - The settings struct loaded from the database
|
||||
#
|
||||
# Returns a map: %{field_name => show_in_overview}
|
||||
#
|
||||
# This can be used for:
|
||||
# - Rendering the overview (filtering visible fields)
|
||||
# - UI configuration dropdowns (showing all fields with their current state)
|
||||
# - Dynamic field management
|
||||
#
|
||||
# Fields are read from the global Constants module.
|
||||
@spec get_member_field_configurations(map()) :: %{atom() => boolean()}
|
||||
defp get_member_field_configurations(settings) do
|
||||
# Get all eligible fields from the global constants
|
||||
all_fields = Mv.Constants.member_fields()
|
||||
|
||||
# Normalize visibility config (JSONB may return string keys)
|
||||
visibility_config = normalize_visibility_config(settings.member_field_visibility || %{})
|
||||
|
||||
Enum.reduce(all_fields, %{}, fn field, acc ->
|
||||
show_in_overview = Map.get(visibility_config, field, true)
|
||||
Map.put(acc, field, show_in_overview)
|
||||
end)
|
||||
end
|
||||
|
||||
|
||||
# Normalizes visibility config map keys from strings to atoms.
|
||||
# JSONB in PostgreSQL converts atom keys to string keys when storing.
|
||||
# This is a local helper to avoid N+1 queries by reusing the normalization logic.
|
||||
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: %{}
|
||||
|
||||
# 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
|
||||
|
|
|
|||
|
|
@ -2,6 +2,13 @@
|
|||
<.header>
|
||||
{gettext("Members")}
|
||||
<: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"}>
|
||||
<.icon name="hero-plus" /> {gettext("New Member")}
|
||||
</.button>
|
||||
|
|
@ -54,6 +61,7 @@
|
|||
</:col>
|
||||
<:col
|
||||
:let={member}
|
||||
:if={:first_name in @member_fields_visible}
|
||||
label={
|
||||
~H"""
|
||||
<.live_component
|
||||
|
|
@ -67,10 +75,29 @@
|
|||
"""
|
||||
}
|
||||
>
|
||||
{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
|
||||
:let={member}
|
||||
:if={:email in @member_fields_visible}
|
||||
label={
|
||||
~H"""
|
||||
<.live_component
|
||||
|
|
@ -88,6 +115,7 @@
|
|||
</:col>
|
||||
<:col
|
||||
:let={member}
|
||||
:if={:street in @member_fields_visible}
|
||||
label={
|
||||
~H"""
|
||||
<.live_component
|
||||
|
|
@ -105,6 +133,7 @@
|
|||
</:col>
|
||||
<:col
|
||||
:let={member}
|
||||
:if={:house_number in @member_fields_visible}
|
||||
label={
|
||||
~H"""
|
||||
<.live_component
|
||||
|
|
@ -122,6 +151,7 @@
|
|||
</:col>
|
||||
<:col
|
||||
:let={member}
|
||||
:if={:postal_code in @member_fields_visible}
|
||||
label={
|
||||
~H"""
|
||||
<.live_component
|
||||
|
|
@ -139,6 +169,7 @@
|
|||
</:col>
|
||||
<:col
|
||||
:let={member}
|
||||
:if={:city in @member_fields_visible}
|
||||
label={
|
||||
~H"""
|
||||
<.live_component
|
||||
|
|
@ -156,6 +187,7 @@
|
|||
</:col>
|
||||
<:col
|
||||
:let={member}
|
||||
:if={:phone_number in @member_fields_visible}
|
||||
label={
|
||||
~H"""
|
||||
<.live_component
|
||||
|
|
@ -173,6 +205,7 @@
|
|||
</:col>
|
||||
<:col
|
||||
:let={member}
|
||||
:if={:join_date in @member_fields_visible}
|
||||
label={
|
||||
~H"""
|
||||
<.live_component
|
||||
|
|
|
|||
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
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
defmodule Mv.Repo.Migrations.AddMemberFieldVisibilityToSettings do
|
||||
@moduledoc """
|
||||
Updates resources based on their most recent snapshots.
|
||||
|
||||
This file was autogenerated with `mix ash_postgres.generate_migrations`
|
||||
"""
|
||||
|
||||
use Ecto.Migration
|
||||
|
||||
def up do
|
||||
alter table(:settings) do
|
||||
add :member_field_visibility, :map
|
||||
end
|
||||
end
|
||||
|
||||
def down do
|
||||
alter table(:settings) do
|
||||
remove :member_field_visibility
|
||||
end
|
||||
end
|
||||
end
|
||||
144
priv/resource_snapshots/repo/custom_fields/20251201115939.json
Normal file
144
priv/resource_snapshots/repo/custom_fields/20251201115939.json
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
{
|
||||
"attributes": [
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "fragment(\"gen_random_uuid()\")",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": true,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "id",
|
||||
"type": "uuid"
|
||||
},
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "name",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "slug",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "value_type",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"allow_nil?": true,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "description",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "false",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "immutable",
|
||||
"type": "boolean"
|
||||
},
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "false",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "required",
|
||||
"type": "boolean"
|
||||
},
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "true",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "show_in_overview",
|
||||
"type": "boolean"
|
||||
}
|
||||
],
|
||||
"base_filter": null,
|
||||
"check_constraints": [],
|
||||
"custom_indexes": [],
|
||||
"custom_statements": [],
|
||||
"has_create_action": true,
|
||||
"hash": "D31160C95D3D32BA715D493DE2D2B8D6572E0EC68AE14B928D99975BC8A81542",
|
||||
"identities": [
|
||||
{
|
||||
"all_tenants?": false,
|
||||
"base_filter": null,
|
||||
"index_name": "custom_fields_unique_name_index",
|
||||
"keys": [
|
||||
{
|
||||
"type": "atom",
|
||||
"value": "name"
|
||||
}
|
||||
],
|
||||
"name": "unique_name",
|
||||
"nils_distinct?": true,
|
||||
"where": null
|
||||
},
|
||||
{
|
||||
"all_tenants?": false,
|
||||
"base_filter": null,
|
||||
"index_name": "custom_fields_unique_slug_index",
|
||||
"keys": [
|
||||
{
|
||||
"type": "atom",
|
||||
"value": "slug"
|
||||
}
|
||||
],
|
||||
"name": "unique_slug",
|
||||
"nils_distinct?": true,
|
||||
"where": null
|
||||
}
|
||||
],
|
||||
"multitenancy": {
|
||||
"attribute": null,
|
||||
"global": null,
|
||||
"strategy": null
|
||||
},
|
||||
"repo": "Elixir.Mv.Repo",
|
||||
"schema": null,
|
||||
"table": "custom_fields"
|
||||
}
|
||||
79
priv/resource_snapshots/repo/settings/20251201115939.json
Normal file
79
priv/resource_snapshots/repo/settings/20251201115939.json
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
{
|
||||
"attributes": [
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "fragment(\"gen_random_uuid()\")",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": true,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "id",
|
||||
"type": "uuid"
|
||||
},
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "club_name",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"allow_nil?": true,
|
||||
"default": "nil",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "member_field_visibility",
|
||||
"type": "map"
|
||||
},
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "fragment(\"(now() AT TIME ZONE 'utc')\")",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "inserted_at",
|
||||
"type": "utc_datetime_usec"
|
||||
},
|
||||
{
|
||||
"allow_nil?": false,
|
||||
"default": "fragment(\"(now() AT TIME ZONE 'utc')\")",
|
||||
"generated?": false,
|
||||
"precision": null,
|
||||
"primary_key?": false,
|
||||
"references": null,
|
||||
"scale": null,
|
||||
"size": null,
|
||||
"source": "updated_at",
|
||||
"type": "utc_datetime_usec"
|
||||
}
|
||||
],
|
||||
"base_filter": null,
|
||||
"check_constraints": [],
|
||||
"custom_indexes": [],
|
||||
"custom_statements": [],
|
||||
"has_create_action": true,
|
||||
"hash": "F2823210AA9E6476074A218375F64CD80E7F9E04EECC4E94D4C7FD31A773C016",
|
||||
"identities": [],
|
||||
"multitenancy": {
|
||||
"attribute": null,
|
||||
"global": null,
|
||||
"strategy": null
|
||||
},
|
||||
"repo": "Elixir.Mv.Repo",
|
||||
"schema": null,
|
||||
"table": "settings"
|
||||
}
|
||||
80
test/membership/member_field_visibility_test.exs
Normal file
80
test/membership/member_field_visibility_test.exs
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
defmodule Mv.Membership.MemberFieldVisibilityTest do
|
||||
@moduledoc """
|
||||
Tests for member field visibility configuration.
|
||||
|
||||
Tests cover:
|
||||
- Member fields are visible by default (show_in_overview: true)
|
||||
- Member fields can be hidden (show_in_overview: false)
|
||||
- Checking if a specific field is visible
|
||||
- Configuration is stored in Settings resource
|
||||
"""
|
||||
use Mv.DataCase, async: true
|
||||
|
||||
alias Mv.Membership.Member
|
||||
|
||||
describe "show_in_overview?/1" do
|
||||
test "returns true for all member fields by default" do
|
||||
# When no settings exist or member_field_visibility is not configured
|
||||
# Test with fields from constants
|
||||
member_fields = Mv.Constants.member_fields()
|
||||
|
||||
Enum.each(member_fields, fn field ->
|
||||
assert Member.show_in_overview?(field) == true,
|
||||
"Field #{field} should be visible by default"
|
||||
end)
|
||||
end
|
||||
|
||||
test "returns false for fields with show_in_overview: false in settings" do
|
||||
# Get or create settings
|
||||
{:ok, settings} = Mv.Membership.get_settings()
|
||||
|
||||
# Use a field that exists in member fields
|
||||
member_fields = Mv.Constants.member_fields()
|
||||
field_to_hide = List.first(member_fields)
|
||||
field_to_show = List.last(member_fields)
|
||||
|
||||
# Update settings to hide a field
|
||||
{:ok, _updated_settings} =
|
||||
Mv.Membership.update_settings(settings, %{
|
||||
member_field_visibility: %{field_to_hide => false}
|
||||
})
|
||||
|
||||
# JSONB may convert atom keys to string keys, so we check via show_in_overview? instead
|
||||
assert Member.show_in_overview?(field_to_hide) == false
|
||||
assert Member.show_in_overview?(field_to_show) == true
|
||||
end
|
||||
|
||||
test "returns true for non-configured fields (default)" do
|
||||
# Get or create settings
|
||||
{:ok, settings} = Mv.Membership.get_settings()
|
||||
|
||||
# Use fields that exist in member fields
|
||||
member_fields = Mv.Constants.member_fields()
|
||||
fields_to_hide = Enum.take(member_fields, 2)
|
||||
fields_to_show = Enum.take(member_fields, -2)
|
||||
|
||||
# Update settings to hide some fields
|
||||
visibility_config =
|
||||
Enum.reduce(fields_to_hide, %{}, fn field, acc ->
|
||||
Map.put(acc, field, false)
|
||||
end)
|
||||
|
||||
{:ok, _updated_settings} =
|
||||
Mv.Membership.update_settings(settings, %{
|
||||
member_field_visibility: visibility_config
|
||||
})
|
||||
|
||||
# Hidden fields should be false
|
||||
Enum.each(fields_to_hide, fn field ->
|
||||
assert Member.show_in_overview?(field) == false,
|
||||
"Field #{field} should be hidden"
|
||||
end)
|
||||
|
||||
# Unconfigured fields should still be true (default)
|
||||
Enum.each(fields_to_show, fn field ->
|
||||
assert Member.show_in_overview?(field) == true,
|
||||
"Field #{field} should be visible by default"
|
||||
end)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -0,0 +1,363 @@
|
|||
defmodule MvWeb.Components.FieldVisibilityDropdownComponentTest do
|
||||
@moduledoc """
|
||||
Tests for FieldVisibilityDropdownComponent LiveComponent.
|
||||
"""
|
||||
use MvWeb.ConnCase, async: true
|
||||
|
||||
import Phoenix.LiveViewTest
|
||||
|
||||
alias MvWeb.Components.FieldVisibilityDropdownComponent
|
||||
|
||||
# Helper to create test assigns
|
||||
defp create_assigns(overrides \\ %{}) do
|
||||
default_assigns = %{
|
||||
id: "test-dropdown",
|
||||
all_fields: [:first_name, :email, :street, "custom_field_123"],
|
||||
custom_fields: [
|
||||
%{id: "123", name: "Custom Field 1"}
|
||||
],
|
||||
selected_fields: %{
|
||||
"first_name" => true,
|
||||
"email" => true,
|
||||
"street" => false,
|
||||
"custom_field_123" => true
|
||||
}
|
||||
}
|
||||
|
||||
Map.merge(default_assigns, overrides)
|
||||
end
|
||||
|
||||
describe "update/2" do
|
||||
test "initializes with default values" do
|
||||
assigns = create_assigns()
|
||||
|
||||
{:ok, socket} = FieldVisibilityDropdownComponent.update(assigns, %{})
|
||||
|
||||
assert socket.assigns.id == "test-dropdown"
|
||||
assert socket.assigns.open == false
|
||||
assert socket.assigns.all_fields == assigns.all_fields
|
||||
assert socket.assigns.selected_fields == assigns.selected_fields
|
||||
end
|
||||
|
||||
test "preserves existing open state" do
|
||||
assigns = create_assigns()
|
||||
existing_socket = %{assigns: %{open: true}}
|
||||
|
||||
{:ok, socket} = FieldVisibilityDropdownComponent.update(assigns, existing_socket)
|
||||
|
||||
assert socket.assigns.open == true
|
||||
end
|
||||
|
||||
test "handles missing optional assigns" do
|
||||
minimal_assigns = %{id: "test"}
|
||||
|
||||
{:ok, socket} = FieldVisibilityDropdownComponent.update(minimal_assigns, %{})
|
||||
|
||||
assert socket.assigns.all_fields == []
|
||||
assert socket.assigns.custom_fields == []
|
||||
assert socket.assigns.selected_fields == %{}
|
||||
end
|
||||
end
|
||||
|
||||
describe "render/1" do
|
||||
test "renders dropdown button" do
|
||||
assigns = create_assigns()
|
||||
|
||||
html = render_component(FieldVisibilityDropdownComponent, assigns)
|
||||
|
||||
assert html =~ "Columns"
|
||||
assert html =~ "hero-adjustments-horizontal"
|
||||
assert has_element?(html, "button[aria-controls='field-visibility-menu']")
|
||||
end
|
||||
|
||||
test "renders dropdown menu when open" do
|
||||
assigns = create_assigns() |> Map.put(:open, true)
|
||||
|
||||
html = render_component(FieldVisibilityDropdownComponent, assigns)
|
||||
|
||||
assert has_element?(html, "ul#field-visibility-menu")
|
||||
assert html =~ "All"
|
||||
assert html =~ "None"
|
||||
end
|
||||
|
||||
test "does not render menu when closed" do
|
||||
assigns = create_assigns() |> Map.put(:open, false)
|
||||
|
||||
html = render_component(FieldVisibilityDropdownComponent, assigns)
|
||||
|
||||
refute has_element?(html, "ul#field-visibility-menu")
|
||||
end
|
||||
|
||||
test "renders member fields" do
|
||||
assigns = create_assigns() |> Map.put(:open, true)
|
||||
|
||||
html = render_component(FieldVisibilityDropdownComponent, assigns)
|
||||
|
||||
# Field names should be formatted (first_name -> First Name)
|
||||
assert html =~ "First Name" or html =~ "first_name"
|
||||
assert html =~ "Email" or html =~ "email"
|
||||
assert html =~ "Street" or html =~ "street"
|
||||
end
|
||||
|
||||
test "renders custom fields when custom fields exist" do
|
||||
assigns = create_assigns() |> Map.put(:open, true)
|
||||
|
||||
html = render_component(FieldVisibilityDropdownComponent, assigns)
|
||||
|
||||
# Custom field name
|
||||
assert html =~ "Custom Field 1"
|
||||
end
|
||||
|
||||
test "renders checkboxes with correct checked state" do
|
||||
assigns = create_assigns() |> Map.put(:open, true)
|
||||
|
||||
html = render_component(FieldVisibilityDropdownComponent, assigns)
|
||||
|
||||
# first_name should be checked (aria-checked="true")
|
||||
assert html =~ ~s(aria-checked="true")
|
||||
assert html =~ ~s(phx-value-item="first_name")
|
||||
|
||||
# street should not be checked (aria-checked="false")
|
||||
assert html =~ ~s(phx-value-item="street")
|
||||
# Note: The visual checkbox state is handled by CSS classes and aria-checked attribute
|
||||
end
|
||||
|
||||
test "includes accessibility attributes" do
|
||||
assigns = create_assigns() |> Map.put(:open, true)
|
||||
|
||||
html = render_component(FieldVisibilityDropdownComponent, assigns)
|
||||
|
||||
assert html =~ ~s(aria-controls="field-visibility-menu")
|
||||
assert html =~ ~s(aria-haspopup="menu")
|
||||
assert html =~ ~s(role="button")
|
||||
assert html =~ ~s(role="menu")
|
||||
assert html =~ ~s(role="menuitemcheckbox")
|
||||
end
|
||||
|
||||
test "formats member field labels correctly" do
|
||||
assigns = create_assigns() |> Map.put(:open, true)
|
||||
|
||||
html = render_component(FieldVisibilityDropdownComponent, assigns)
|
||||
|
||||
# Field names should be formatted (first_name -> First Name)
|
||||
assert html =~ "First Name" or html =~ "first_name"
|
||||
end
|
||||
|
||||
test "uses custom field names from custom_fields prop" do
|
||||
assigns =
|
||||
create_assigns()
|
||||
|> Map.put(:open, true)
|
||||
|> Map.put(:custom_fields, [
|
||||
%{id: "123", name: "Membership Number"}
|
||||
])
|
||||
|
||||
html = render_component(FieldVisibilityDropdownComponent, assigns)
|
||||
|
||||
assert html =~ "Membership Number"
|
||||
end
|
||||
|
||||
test "falls back to ID when custom field not found" do
|
||||
assigns =
|
||||
create_assigns()
|
||||
|> Map.put(:open, true)
|
||||
# Empty custom fields list
|
||||
|> Map.put(:custom_fields, [])
|
||||
|
||||
html = render_component(FieldVisibilityDropdownComponent, assigns)
|
||||
|
||||
# Should show something like "Custom Field 123"
|
||||
assert html =~ "custom_field_123" or html =~ "Custom Field"
|
||||
end
|
||||
end
|
||||
|
||||
describe "handle_event/2" do
|
||||
test "toggle_dropdown toggles open state" do
|
||||
assigns = create_assigns()
|
||||
{:ok, socket} = FieldVisibilityDropdownComponent.update(assigns, %{})
|
||||
|
||||
assert socket.assigns.open == false
|
||||
|
||||
{:noreply, socket} =
|
||||
FieldVisibilityDropdownComponent.handle_event("toggle_dropdown", %{}, socket)
|
||||
|
||||
assert socket.assigns.open == true
|
||||
|
||||
{:noreply, socket} =
|
||||
FieldVisibilityDropdownComponent.handle_event("toggle_dropdown", %{}, socket)
|
||||
|
||||
assert socket.assigns.open == false
|
||||
end
|
||||
|
||||
test "close_dropdown sets open to false" do
|
||||
assigns = create_assigns()
|
||||
{:ok, socket} = FieldVisibilityDropdownComponent.update(assigns, %{})
|
||||
socket = assign(socket, :open, true)
|
||||
|
||||
{:noreply, socket} =
|
||||
FieldVisibilityDropdownComponent.handle_event("close_dropdown", %{}, socket)
|
||||
|
||||
assert socket.assigns.open == false
|
||||
end
|
||||
|
||||
test "select_item toggles field visibility" do
|
||||
assigns = create_assigns()
|
||||
{:ok, socket} = FieldVisibilityDropdownComponent.update(assigns, %{})
|
||||
|
||||
assert socket.assigns.selected_fields["first_name"] == true
|
||||
|
||||
{:noreply, socket} =
|
||||
FieldVisibilityDropdownComponent.handle_event(
|
||||
"select_item",
|
||||
%{"item" => "first_name"},
|
||||
socket
|
||||
)
|
||||
|
||||
assert socket.assigns.selected_fields["first_name"] == false
|
||||
|
||||
{:noreply, socket} =
|
||||
FieldVisibilityDropdownComponent.handle_event(
|
||||
"select_item",
|
||||
%{"item" => "first_name"},
|
||||
socket
|
||||
)
|
||||
|
||||
assert socket.assigns.selected_fields["first_name"] == true
|
||||
end
|
||||
|
||||
test "select_item defaults to true for missing fields" do
|
||||
assigns = create_assigns()
|
||||
{:ok, socket} = FieldVisibilityDropdownComponent.update(assigns, %{})
|
||||
|
||||
{:noreply, socket} =
|
||||
FieldVisibilityDropdownComponent.handle_event(
|
||||
"select_item",
|
||||
%{"item" => "new_field"},
|
||||
socket
|
||||
)
|
||||
|
||||
# Toggled from default true
|
||||
assert socket.assigns.selected_fields["new_field"] == false
|
||||
end
|
||||
|
||||
test "select_item sends message to parent" do
|
||||
assigns = create_assigns()
|
||||
{:ok, socket} = FieldVisibilityDropdownComponent.update(assigns, %{})
|
||||
|
||||
FieldVisibilityDropdownComponent.handle_event(
|
||||
"select_item",
|
||||
%{"item" => "first_name"},
|
||||
socket
|
||||
)
|
||||
|
||||
# Check that message was sent (would be verified in integration test)
|
||||
# For unit test, we just verify the state change
|
||||
assert_receive {:field_toggled, "first_name", false}
|
||||
end
|
||||
|
||||
test "select_all sets all fields to true" do
|
||||
assigns = create_assigns()
|
||||
{:ok, socket} = FieldVisibilityDropdownComponent.update(assigns, %{})
|
||||
|
||||
{:noreply, socket} =
|
||||
FieldVisibilityDropdownComponent.handle_event("select_all", %{}, socket)
|
||||
|
||||
assert socket.assigns.selected_fields["first_name"] == true
|
||||
assert socket.assigns.selected_fields["email"] == true
|
||||
assert socket.assigns.selected_fields["street"] == true
|
||||
assert socket.assigns.selected_fields["custom_field_123"] == true
|
||||
end
|
||||
|
||||
test "select_all sends message to parent" do
|
||||
assigns = create_assigns()
|
||||
{:ok, socket} = FieldVisibilityDropdownComponent.update(assigns, %{})
|
||||
|
||||
FieldVisibilityDropdownComponent.handle_event("select_all", %{}, socket)
|
||||
|
||||
assert_receive {:fields_selected, selection}
|
||||
assert selection["first_name"] == true
|
||||
assert selection["email"] == true
|
||||
end
|
||||
|
||||
test "select_none sets all fields to false" do
|
||||
assigns = create_assigns()
|
||||
{:ok, socket} = FieldVisibilityDropdownComponent.update(assigns, %{})
|
||||
|
||||
{:noreply, socket} =
|
||||
FieldVisibilityDropdownComponent.handle_event("select_none", %{}, socket)
|
||||
|
||||
assert socket.assigns.selected_fields["first_name"] == false
|
||||
assert socket.assigns.selected_fields["email"] == false
|
||||
assert socket.assigns.selected_fields["street"] == false
|
||||
assert socket.assigns.selected_fields["custom_field_123"] == false
|
||||
end
|
||||
|
||||
test "select_none sends message to parent" do
|
||||
assigns = create_assigns()
|
||||
{:ok, socket} = FieldVisibilityDropdownComponent.update(assigns, %{})
|
||||
|
||||
FieldVisibilityDropdownComponent.handle_event("select_none", %{}, socket)
|
||||
|
||||
assert_receive {:fields_selected, selection}
|
||||
assert selection["first_name"] == false
|
||||
assert selection["email"] == false
|
||||
end
|
||||
|
||||
test "handles custom field toggle" do
|
||||
assigns = create_assigns()
|
||||
{:ok, socket} = FieldVisibilityDropdownComponent.update(assigns, %{})
|
||||
|
||||
{:noreply, socket} =
|
||||
FieldVisibilityDropdownComponent.handle_event(
|
||||
"select_item",
|
||||
%{"item" => "custom_field_123"},
|
||||
socket
|
||||
)
|
||||
|
||||
assert socket.assigns.selected_fields["custom_field_123"] == false
|
||||
end
|
||||
end
|
||||
|
||||
describe "integration with LiveView" do
|
||||
test "component can be rendered in LiveView" do
|
||||
conn = conn_with_oidc_user(build_conn())
|
||||
{:ok, view, _html} = live(conn, "/members")
|
||||
|
||||
# Check that component is rendered
|
||||
assert has_element?(view, "button[aria-controls='field-visibility-menu']")
|
||||
end
|
||||
|
||||
test "clicking button opens dropdown" do
|
||||
conn = conn_with_oidc_user(build_conn())
|
||||
{:ok, view, _html} = live(conn, "/members")
|
||||
|
||||
# Initially closed
|
||||
refute has_element?(view, "ul#field-visibility-menu")
|
||||
|
||||
# Click button
|
||||
view
|
||||
|> element("button[aria-controls='field-visibility-menu']")
|
||||
|> render_click()
|
||||
|
||||
# Should be open now
|
||||
assert has_element?(view, "ul#field-visibility-menu")
|
||||
end
|
||||
|
||||
test "toggling field updates selection" do
|
||||
conn = conn_with_oidc_user(build_conn())
|
||||
{:ok, view, _html} = live(conn, "/members")
|
||||
|
||||
# Open dropdown
|
||||
view
|
||||
|> element("button[aria-controls='field-visibility-menu']")
|
||||
|> render_click()
|
||||
|
||||
# Toggle a field
|
||||
view
|
||||
|> element("button[phx-click='select_item'][phx-value-item='first_name']")
|
||||
|> render_click()
|
||||
|
||||
# Component should update (verified by state change)
|
||||
# In a real scenario, this would trigger a reload of members
|
||||
end
|
||||
end
|
||||
end
|
||||
346
test/mv_web/live/member_live/index/field_selection_test.exs
Normal file
346
test/mv_web/live/member_live/index/field_selection_test.exs
Normal file
|
|
@ -0,0 +1,346 @@
|
|||
defmodule MvWeb.MemberLive.Index.FieldSelectionTest do
|
||||
@moduledoc """
|
||||
Tests for FieldSelection module handling cookie/session/URL management.
|
||||
"""
|
||||
use ExUnit.Case, async: true
|
||||
|
||||
alias MvWeb.MemberLive.Index.FieldSelection
|
||||
|
||||
describe "get_from_session/1" do
|
||||
test "returns empty map when session is empty" do
|
||||
assert FieldSelection.get_from_session(%{}) == %{}
|
||||
end
|
||||
|
||||
test "returns empty map when session key is missing" do
|
||||
session = %{"other_key" => "value"}
|
||||
assert FieldSelection.get_from_session(session) == %{}
|
||||
end
|
||||
|
||||
test "parses valid JSON from session" do
|
||||
json = Jason.encode!(%{"first_name" => true, "email" => false})
|
||||
session = %{"member_field_selection" => json}
|
||||
|
||||
result = FieldSelection.get_from_session(session)
|
||||
|
||||
assert result == %{"first_name" => true, "email" => false}
|
||||
end
|
||||
|
||||
test "handles invalid JSON gracefully" do
|
||||
session = %{"member_field_selection" => "invalid json{["}
|
||||
|
||||
result = FieldSelection.get_from_session(session)
|
||||
|
||||
assert result == %{}
|
||||
end
|
||||
|
||||
test "converts non-boolean values to true" do
|
||||
json = Jason.encode!(%{"first_name" => "true", "email" => 1, "street" => true})
|
||||
session = %{"member_field_selection" => json}
|
||||
|
||||
result = FieldSelection.get_from_session(session)
|
||||
|
||||
# All values should be booleans, non-booleans default to true
|
||||
assert result["first_name"] == true
|
||||
assert result["email"] == true
|
||||
assert result["street"] == true
|
||||
end
|
||||
|
||||
test "handles nil session" do
|
||||
assert FieldSelection.get_from_session(nil) == %{}
|
||||
end
|
||||
|
||||
test "handles non-map session" do
|
||||
assert FieldSelection.get_from_session("not a map") == %{}
|
||||
end
|
||||
end
|
||||
|
||||
describe "save_to_session/2" do
|
||||
test "saves field selection to session as JSON" do
|
||||
session = %{}
|
||||
selection = %{"first_name" => true, "email" => false}
|
||||
|
||||
result = FieldSelection.save_to_session(session, selection)
|
||||
|
||||
assert Map.has_key?(result, "member_field_selection")
|
||||
assert Jason.decode!(result["member_field_selection"]) == selection
|
||||
end
|
||||
|
||||
test "overwrites existing selection" do
|
||||
session = %{"member_field_selection" => Jason.encode!(%{"old" => true})}
|
||||
selection = %{"new" => true}
|
||||
|
||||
result = FieldSelection.save_to_session(session, selection)
|
||||
|
||||
assert Jason.decode!(result["member_field_selection"]) == selection
|
||||
end
|
||||
|
||||
test "handles empty selection" do
|
||||
session = %{}
|
||||
selection = %{}
|
||||
|
||||
result = FieldSelection.save_to_session(session, selection)
|
||||
|
||||
assert Jason.decode!(result["member_field_selection"]) == %{}
|
||||
end
|
||||
|
||||
test "handles invalid selection gracefully" do
|
||||
session = %{}
|
||||
|
||||
result = FieldSelection.save_to_session(session, "not a map")
|
||||
|
||||
assert result == session
|
||||
end
|
||||
end
|
||||
|
||||
describe "get_from_cookie/1" do
|
||||
test "returns empty map when cookie is missing" do
|
||||
conn = Plug.Conn.put_req_header(%Plug.Conn{}, "cookie", "")
|
||||
|
||||
result = FieldSelection.get_from_cookie(conn)
|
||||
|
||||
assert result == %{}
|
||||
end
|
||||
|
||||
test "parses valid JSON from cookie" do
|
||||
json = Jason.encode!(%{"first_name" => true, "email" => false})
|
||||
conn = Plug.Conn.put_req_cookie(%Plug.Conn{}, "member_field_selection", json)
|
||||
|
||||
result = FieldSelection.get_from_cookie(conn)
|
||||
|
||||
assert result == %{"first_name" => true, "email" => false}
|
||||
end
|
||||
|
||||
test "handles invalid JSON in cookie gracefully" do
|
||||
conn = Plug.Conn.put_req_cookie(%Plug.Conn{}, "member_field_selection", "invalid{[")
|
||||
|
||||
result = FieldSelection.get_from_cookie(conn)
|
||||
|
||||
assert result == %{}
|
||||
end
|
||||
end
|
||||
|
||||
describe "save_to_cookie/2" do
|
||||
test "saves field selection to cookie" do
|
||||
conn = %Plug.Conn{}
|
||||
selection = %{"first_name" => true, "email" => false}
|
||||
|
||||
result = FieldSelection.save_to_cookie(conn, selection)
|
||||
|
||||
# Check that cookie is set
|
||||
assert result.resp_cookies["member_field_selection"]
|
||||
cookie = result.resp_cookies["member_field_selection"]
|
||||
assert cookie[:max_age] == 365 * 24 * 60 * 60
|
||||
assert cookie[:same_site] == "Lax"
|
||||
assert cookie[:http_only] == true
|
||||
end
|
||||
|
||||
test "handles invalid selection gracefully" do
|
||||
conn = %Plug.Conn{}
|
||||
|
||||
result = FieldSelection.save_to_cookie(conn, "not a map")
|
||||
|
||||
assert result == conn
|
||||
end
|
||||
end
|
||||
|
||||
describe "parse_from_url/1" do
|
||||
test "returns empty map when params is empty" do
|
||||
assert FieldSelection.parse_from_url(%{}) == %{}
|
||||
end
|
||||
|
||||
test "returns empty map when fields parameter is missing" do
|
||||
params = %{"query" => "test", "sort_field" => "first_name"}
|
||||
assert FieldSelection.parse_from_url(params) == %{}
|
||||
end
|
||||
|
||||
test "parses comma-separated field names" do
|
||||
params = %{"fields" => "first_name,email,street"}
|
||||
|
||||
result = FieldSelection.parse_from_url(params)
|
||||
|
||||
assert result == %{
|
||||
"first_name" => true,
|
||||
"email" => true,
|
||||
"street" => true
|
||||
}
|
||||
end
|
||||
|
||||
test "handles custom field names" do
|
||||
params = %{"fields" => "custom_field_abc-123,custom_field_def-456"}
|
||||
|
||||
result = FieldSelection.parse_from_url(params)
|
||||
|
||||
assert result == %{
|
||||
"custom_field_abc-123" => true,
|
||||
"custom_field_def-456" => true
|
||||
}
|
||||
end
|
||||
|
||||
test "handles mixed member and custom fields" do
|
||||
params = %{"fields" => "first_name,custom_field_123,email"}
|
||||
|
||||
result = FieldSelection.parse_from_url(params)
|
||||
|
||||
assert result == %{
|
||||
"first_name" => true,
|
||||
"custom_field_123" => true,
|
||||
"email" => true
|
||||
}
|
||||
end
|
||||
|
||||
test "trims whitespace from field names" do
|
||||
params = %{"fields" => " first_name , email , street "}
|
||||
|
||||
result = FieldSelection.parse_from_url(params)
|
||||
|
||||
assert result == %{
|
||||
"first_name" => true,
|
||||
"email" => true,
|
||||
"street" => true
|
||||
}
|
||||
end
|
||||
|
||||
test "handles empty fields string" do
|
||||
params = %{"fields" => ""}
|
||||
assert FieldSelection.parse_from_url(params) == %{}
|
||||
end
|
||||
|
||||
test "handles nil fields parameter" do
|
||||
params = %{"fields" => nil}
|
||||
assert FieldSelection.parse_from_url(params) == %{}
|
||||
end
|
||||
|
||||
test "filters out empty field names" do
|
||||
params = %{"fields" => "first_name,,email,"}
|
||||
|
||||
result = FieldSelection.parse_from_url(params)
|
||||
|
||||
assert result == %{
|
||||
"first_name" => true,
|
||||
"email" => true
|
||||
}
|
||||
end
|
||||
|
||||
test "handles non-map params" do
|
||||
assert FieldSelection.parse_from_url(nil) == %{}
|
||||
assert FieldSelection.parse_from_url("not a map") == %{}
|
||||
end
|
||||
end
|
||||
|
||||
describe "merge_sources/3" do
|
||||
test "merges all sources with URL having highest priority" do
|
||||
url_selection = %{"first_name" => false}
|
||||
session_selection = %{"first_name" => true, "email" => true}
|
||||
cookie_selection = %{"first_name" => true, "street" => true}
|
||||
|
||||
result = FieldSelection.merge_sources(url_selection, session_selection, cookie_selection)
|
||||
|
||||
# URL overrides session, session overrides cookie
|
||||
assert result["first_name"] == false
|
||||
assert result["email"] == true
|
||||
assert result["street"] == true
|
||||
end
|
||||
|
||||
test "handles empty sources" do
|
||||
result = FieldSelection.merge_sources(%{}, %{}, %{})
|
||||
|
||||
assert result == %{}
|
||||
end
|
||||
|
||||
test "cookie only" do
|
||||
cookie_selection = %{"first_name" => true}
|
||||
|
||||
result = FieldSelection.merge_sources(%{}, %{}, cookie_selection)
|
||||
|
||||
assert result == %{"first_name" => true}
|
||||
end
|
||||
|
||||
test "session overrides cookie" do
|
||||
session_selection = %{"first_name" => false}
|
||||
cookie_selection = %{"first_name" => true}
|
||||
|
||||
result = FieldSelection.merge_sources(%{}, session_selection, cookie_selection)
|
||||
|
||||
assert result["first_name"] == false
|
||||
end
|
||||
|
||||
test "URL overrides everything" do
|
||||
url_selection = %{"first_name" => true}
|
||||
session_selection = %{"first_name" => false}
|
||||
cookie_selection = %{"first_name" => false}
|
||||
|
||||
result = FieldSelection.merge_sources(url_selection, session_selection, cookie_selection)
|
||||
|
||||
assert result["first_name"] == true
|
||||
end
|
||||
|
||||
test "combines fields from all sources" do
|
||||
url_selection = %{"url_field" => true}
|
||||
session_selection = %{"session_field" => true}
|
||||
cookie_selection = %{"cookie_field" => true}
|
||||
|
||||
result = FieldSelection.merge_sources(url_selection, session_selection, cookie_selection)
|
||||
|
||||
assert result["url_field"] == true
|
||||
assert result["session_field"] == true
|
||||
assert result["cookie_field"] == true
|
||||
end
|
||||
end
|
||||
|
||||
describe "to_url_param/1" do
|
||||
test "converts selection to comma-separated string" do
|
||||
selection = %{"first_name" => true, "email" => true, "street" => false}
|
||||
|
||||
result = FieldSelection.to_url_param(selection)
|
||||
|
||||
# Only visible fields should be included
|
||||
assert result == "first_name,email"
|
||||
end
|
||||
|
||||
test "handles empty selection" do
|
||||
assert FieldSelection.to_url_param(%{}) == ""
|
||||
end
|
||||
|
||||
test "handles all fields hidden" do
|
||||
selection = %{"first_name" => false, "email" => false}
|
||||
|
||||
result = FieldSelection.to_url_param(selection)
|
||||
|
||||
assert result == ""
|
||||
end
|
||||
|
||||
test "preserves field order" do
|
||||
selection = %{
|
||||
"z_field" => true,
|
||||
"a_field" => true,
|
||||
"m_field" => true
|
||||
}
|
||||
|
||||
result = FieldSelection.to_url_param(selection)
|
||||
|
||||
# Order should be preserved (map iteration order)
|
||||
assert String.contains?(result, "z_field")
|
||||
assert String.contains?(result, "a_field")
|
||||
assert String.contains?(result, "m_field")
|
||||
end
|
||||
|
||||
test "handles custom fields" do
|
||||
selection = %{
|
||||
"first_name" => true,
|
||||
"custom_field_abc-123" => true,
|
||||
"email" => false
|
||||
}
|
||||
|
||||
result = FieldSelection.to_url_param(selection)
|
||||
|
||||
assert String.contains?(result, "first_name")
|
||||
assert String.contains?(result, "custom_field_abc-123")
|
||||
refute String.contains?(result, "email")
|
||||
end
|
||||
|
||||
test "handles invalid input" do
|
||||
assert FieldSelection.to_url_param(nil) == ""
|
||||
assert FieldSelection.to_url_param("not a map") == ""
|
||||
end
|
||||
end
|
||||
end
|
||||
336
test/mv_web/live/member_live/index/field_visibility_test.exs
Normal file
336
test/mv_web/live/member_live/index/field_visibility_test.exs
Normal file
|
|
@ -0,0 +1,336 @@
|
|||
defmodule MvWeb.MemberLive.Index.FieldVisibilityTest do
|
||||
@moduledoc """
|
||||
Tests for FieldVisibility module handling field visibility merging logic.
|
||||
"""
|
||||
use ExUnit.Case, async: true
|
||||
|
||||
alias MvWeb.MemberLive.Index.FieldVisibility
|
||||
|
||||
# Mock custom field structs for testing
|
||||
defp create_custom_field(id, name, show_in_overview \\ true) do
|
||||
%{
|
||||
id: id,
|
||||
name: name,
|
||||
show_in_overview: show_in_overview
|
||||
}
|
||||
end
|
||||
|
||||
describe "get_all_available_fields/1" do
|
||||
test "returns member fields and custom fields" do
|
||||
custom_fields = [
|
||||
create_custom_field("cf1", "Custom Field 1"),
|
||||
create_custom_field("cf2", "Custom Field 2")
|
||||
]
|
||||
|
||||
result = FieldVisibility.get_all_available_fields(custom_fields)
|
||||
|
||||
# Should include all member fields
|
||||
assert :first_name in result
|
||||
assert :email in result
|
||||
assert :street in result
|
||||
|
||||
# Should include custom fields as strings
|
||||
assert "custom_field_cf1" in result
|
||||
assert "custom_field_cf2" in result
|
||||
end
|
||||
|
||||
test "handles empty custom fields list" do
|
||||
result = FieldVisibility.get_all_available_fields([])
|
||||
|
||||
# Should only have member fields
|
||||
assert :first_name in result
|
||||
assert :email in result
|
||||
|
||||
refute Enum.any?(result, fn field ->
|
||||
is_binary(field) and String.starts_with?(field, "custom_field_")
|
||||
end)
|
||||
end
|
||||
|
||||
test "includes all member fields from constants" do
|
||||
custom_fields = []
|
||||
result = FieldVisibility.get_all_available_fields(custom_fields)
|
||||
|
||||
member_fields = Mv.Constants.member_fields()
|
||||
|
||||
Enum.each(member_fields, fn field ->
|
||||
assert field in result
|
||||
end)
|
||||
end
|
||||
end
|
||||
|
||||
describe "merge_with_global_settings/3" do
|
||||
test "user selection overrides global settings" do
|
||||
user_selection = %{"first_name" => false}
|
||||
settings = %{member_field_visibility: %{first_name: true, email: true}}
|
||||
custom_fields = []
|
||||
|
||||
result = FieldVisibility.merge_with_global_settings(user_selection, settings, custom_fields)
|
||||
|
||||
assert result["first_name"] == false
|
||||
assert result["email"] == true
|
||||
end
|
||||
|
||||
test "falls back to global settings when user selection is empty" do
|
||||
user_selection = %{}
|
||||
settings = %{member_field_visibility: %{first_name: false, email: true}}
|
||||
custom_fields = []
|
||||
|
||||
result = FieldVisibility.merge_with_global_settings(user_selection, settings, custom_fields)
|
||||
|
||||
assert result["first_name"] == false
|
||||
assert result["email"] == true
|
||||
end
|
||||
|
||||
test "defaults to true when field not in settings" do
|
||||
user_selection = %{}
|
||||
settings = %{member_field_visibility: %{first_name: false}}
|
||||
custom_fields = []
|
||||
|
||||
result = FieldVisibility.merge_with_global_settings(user_selection, settings, custom_fields)
|
||||
|
||||
# first_name from settings
|
||||
assert result["first_name"] == false
|
||||
# email defaults to true (not in settings)
|
||||
assert result["email"] == true
|
||||
end
|
||||
|
||||
test "handles custom fields visibility" do
|
||||
user_selection = %{}
|
||||
settings = %{member_field_visibility: %{}}
|
||||
|
||||
custom_fields = [
|
||||
create_custom_field("cf1", "Custom 1", true),
|
||||
create_custom_field("cf2", "Custom 2", false)
|
||||
]
|
||||
|
||||
result = FieldVisibility.merge_with_global_settings(user_selection, settings, custom_fields)
|
||||
|
||||
assert result["custom_field_cf1"] == true
|
||||
assert result["custom_field_cf2"] == false
|
||||
end
|
||||
|
||||
test "user selection overrides custom field visibility" do
|
||||
user_selection = %{"custom_field_cf1" => false}
|
||||
settings = %{member_field_visibility: %{}}
|
||||
|
||||
custom_fields = [
|
||||
create_custom_field("cf1", "Custom 1", true)
|
||||
]
|
||||
|
||||
result = FieldVisibility.merge_with_global_settings(user_selection, settings, custom_fields)
|
||||
|
||||
assert result["custom_field_cf1"] == false
|
||||
end
|
||||
|
||||
test "handles string keys in settings (JSONB format)" do
|
||||
user_selection = %{}
|
||||
settings = %{member_field_visibility: %{"first_name" => false, "email" => true}}
|
||||
custom_fields = []
|
||||
|
||||
result = FieldVisibility.merge_with_global_settings(user_selection, settings, custom_fields)
|
||||
|
||||
assert result["first_name"] == false
|
||||
assert result["email"] == true
|
||||
end
|
||||
|
||||
test "handles mixed atom and string keys in settings" do
|
||||
user_selection = %{}
|
||||
# Use string keys only (as JSONB would return)
|
||||
settings = %{member_field_visibility: %{"first_name" => false, "email" => true}}
|
||||
custom_fields = []
|
||||
|
||||
result = FieldVisibility.merge_with_global_settings(user_selection, settings, custom_fields)
|
||||
|
||||
assert result["first_name"] == false
|
||||
assert result["email"] == true
|
||||
end
|
||||
|
||||
test "handles nil settings gracefully" do
|
||||
user_selection = %{}
|
||||
settings = %{member_field_visibility: nil}
|
||||
custom_fields = []
|
||||
|
||||
result = FieldVisibility.merge_with_global_settings(user_selection, settings, custom_fields)
|
||||
|
||||
# Should default all fields to true
|
||||
assert result["first_name"] == true
|
||||
assert result["email"] == true
|
||||
end
|
||||
|
||||
test "handles missing member_field_visibility key" do
|
||||
user_selection = %{}
|
||||
settings = %{}
|
||||
custom_fields = []
|
||||
|
||||
result = FieldVisibility.merge_with_global_settings(user_selection, settings, custom_fields)
|
||||
|
||||
# Should default all fields to true
|
||||
assert result["first_name"] == true
|
||||
assert result["email"] == true
|
||||
end
|
||||
|
||||
test "includes all fields in result" do
|
||||
user_selection = %{"first_name" => false}
|
||||
settings = %{member_field_visibility: %{email: true}}
|
||||
|
||||
custom_fields = [
|
||||
create_custom_field("cf1", "Custom 1", true)
|
||||
]
|
||||
|
||||
result = FieldVisibility.merge_with_global_settings(user_selection, settings, custom_fields)
|
||||
|
||||
# Should include all member fields
|
||||
member_fields = Mv.Constants.member_fields()
|
||||
|
||||
Enum.each(member_fields, fn field ->
|
||||
assert Map.has_key?(result, Atom.to_string(field))
|
||||
end)
|
||||
|
||||
# Should include custom fields
|
||||
assert Map.has_key?(result, "custom_field_cf1")
|
||||
end
|
||||
end
|
||||
|
||||
describe "get_visible_fields/1" do
|
||||
test "returns only fields with true visibility" do
|
||||
selection = %{
|
||||
"first_name" => true,
|
||||
"email" => false,
|
||||
"street" => true,
|
||||
"custom_field_123" => false
|
||||
}
|
||||
|
||||
result = FieldVisibility.get_visible_fields(selection)
|
||||
|
||||
assert :first_name in result
|
||||
assert :street in result
|
||||
refute :email in result
|
||||
refute "custom_field_123" in result
|
||||
end
|
||||
|
||||
test "converts member field strings to atoms" do
|
||||
selection = %{"first_name" => true, "email" => true}
|
||||
|
||||
result = FieldVisibility.get_visible_fields(selection)
|
||||
|
||||
assert :first_name in result
|
||||
assert :email in result
|
||||
end
|
||||
|
||||
test "keeps custom fields as strings" do
|
||||
selection = %{"custom_field_abc-123" => true}
|
||||
|
||||
result = FieldVisibility.get_visible_fields(selection)
|
||||
|
||||
assert "custom_field_abc-123" in result
|
||||
end
|
||||
|
||||
test "handles empty selection" do
|
||||
assert FieldVisibility.get_visible_fields(%{}) == []
|
||||
end
|
||||
|
||||
test "handles all fields hidden" do
|
||||
selection = %{"first_name" => false, "email" => false}
|
||||
|
||||
assert FieldVisibility.get_visible_fields(selection) == []
|
||||
end
|
||||
|
||||
test "handles invalid input" do
|
||||
assert FieldVisibility.get_visible_fields(nil) == []
|
||||
end
|
||||
end
|
||||
|
||||
describe "get_visible_member_fields/1" do
|
||||
test "returns only member fields that are visible" do
|
||||
selection = %{
|
||||
"first_name" => true,
|
||||
"email" => true,
|
||||
"custom_field_123" => true,
|
||||
"street" => false
|
||||
}
|
||||
|
||||
result = FieldVisibility.get_visible_member_fields(selection)
|
||||
|
||||
assert :first_name in result
|
||||
assert :email in result
|
||||
refute :street in result
|
||||
refute "custom_field_123" in result
|
||||
end
|
||||
|
||||
test "filters out custom fields" do
|
||||
selection = %{
|
||||
"first_name" => true,
|
||||
"custom_field_123" => true,
|
||||
"custom_field_456" => true
|
||||
}
|
||||
|
||||
result = FieldVisibility.get_visible_member_fields(selection)
|
||||
|
||||
assert :first_name in result
|
||||
refute "custom_field_123" in result
|
||||
refute "custom_field_456" in result
|
||||
end
|
||||
|
||||
test "handles empty selection" do
|
||||
assert FieldVisibility.get_visible_member_fields(%{}) == []
|
||||
end
|
||||
|
||||
test "handles invalid input" do
|
||||
assert FieldVisibility.get_visible_member_fields(nil) == []
|
||||
end
|
||||
end
|
||||
|
||||
describe "get_visible_custom_fields/1" do
|
||||
test "returns only custom fields that are visible" do
|
||||
selection = %{
|
||||
"first_name" => true,
|
||||
"custom_field_123" => true,
|
||||
"custom_field_456" => false,
|
||||
"email" => true
|
||||
}
|
||||
|
||||
result = FieldVisibility.get_visible_custom_fields(selection)
|
||||
|
||||
assert "custom_field_123" in result
|
||||
refute "custom_field_456" in result
|
||||
refute :first_name in result
|
||||
refute :email in result
|
||||
end
|
||||
|
||||
test "filters out member fields" do
|
||||
selection = %{
|
||||
"first_name" => true,
|
||||
"email" => true,
|
||||
"custom_field_123" => true
|
||||
}
|
||||
|
||||
result = FieldVisibility.get_visible_custom_fields(selection)
|
||||
|
||||
assert "custom_field_123" in result
|
||||
refute :first_name in result
|
||||
refute :email in result
|
||||
end
|
||||
|
||||
test "handles empty selection" do
|
||||
assert FieldVisibility.get_visible_custom_fields(%{}) == []
|
||||
end
|
||||
|
||||
test "handles fields that look like custom fields but aren't" do
|
||||
selection = %{
|
||||
"custom_field_123" => true,
|
||||
"custom_field_like_name" => true,
|
||||
"not_custom_field" => true
|
||||
}
|
||||
|
||||
result = FieldVisibility.get_visible_custom_fields(selection)
|
||||
|
||||
assert "custom_field_123" in result
|
||||
assert "custom_field_like_name" in result
|
||||
refute "not_custom_field" in result
|
||||
end
|
||||
|
||||
test "handles invalid input" do
|
||||
assert FieldVisibility.get_visible_custom_fields(nil) == []
|
||||
end
|
||||
end
|
||||
end
|
||||
509
test/mv_web/member_live/index_field_visibility_test.exs
Normal file
509
test/mv_web/member_live/index_field_visibility_test.exs
Normal file
|
|
@ -0,0 +1,509 @@
|
|||
defmodule MvWeb.MemberLive.IndexFieldVisibilityTest do
|
||||
@moduledoc """
|
||||
Integration tests for field visibility dropdown functionality.
|
||||
|
||||
Tests cover:
|
||||
- Field selection dropdown rendering
|
||||
- Toggling field visibility
|
||||
- URL parameter persistence
|
||||
- Select all / deselect all
|
||||
- Integration with member list display
|
||||
- Custom fields visibility
|
||||
"""
|
||||
use MvWeb.ConnCase, async: true
|
||||
|
||||
import Phoenix.LiveViewTest
|
||||
require Ash.Query
|
||||
|
||||
alias Mv.Membership.{CustomField, CustomFieldValue, Member}
|
||||
|
||||
setup do
|
||||
# Create test members
|
||||
{:ok, member1} =
|
||||
Member
|
||||
|> Ash.Changeset.for_create(:create_member, %{
|
||||
first_name: "Alice",
|
||||
last_name: "Anderson",
|
||||
email: "alice@example.com",
|
||||
street: "Main St",
|
||||
city: "Berlin"
|
||||
})
|
||||
|> Ash.create()
|
||||
|
||||
{:ok, member2} =
|
||||
Member
|
||||
|> Ash.Changeset.for_create(:create_member, %{
|
||||
first_name: "Bob",
|
||||
last_name: "Brown",
|
||||
email: "bob@example.com",
|
||||
street: "Second St",
|
||||
city: "Hamburg"
|
||||
})
|
||||
|> Ash.create()
|
||||
|
||||
# Create custom field
|
||||
{:ok, custom_field} =
|
||||
CustomField
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
name: "membership_number",
|
||||
value_type: :string,
|
||||
show_in_overview: true
|
||||
})
|
||||
|> Ash.create()
|
||||
|
||||
# Create custom field values
|
||||
{:ok, _cfv1} =
|
||||
CustomFieldValue
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
member_id: member1.id,
|
||||
custom_field_id: custom_field.id,
|
||||
value: "M001"
|
||||
})
|
||||
|> Ash.create()
|
||||
|
||||
{:ok, _cfv2} =
|
||||
CustomFieldValue
|
||||
|> Ash.Changeset.for_create(:create, %{
|
||||
member_id: member2.id,
|
||||
custom_field_id: custom_field.id,
|
||||
value: "M002"
|
||||
})
|
||||
|> Ash.create()
|
||||
|
||||
%{
|
||||
member1: member1,
|
||||
member2: member2,
|
||||
custom_field: custom_field
|
||||
}
|
||||
end
|
||||
|
||||
describe "field visibility dropdown" do
|
||||
test "renders dropdown button", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, _view, html} = live(conn, "/members")
|
||||
|
||||
assert html =~ "Columns"
|
||||
assert html =~ ~s(aria-controls="field-visibility-menu")
|
||||
end
|
||||
|
||||
test "opens dropdown when button is clicked", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, view, _html} = live(conn, "/members")
|
||||
|
||||
# Initially closed
|
||||
refute has_element?(view, "ul#field-visibility-menu")
|
||||
|
||||
# Click button
|
||||
view
|
||||
|> element("button[aria-controls='field-visibility-menu']")
|
||||
|> render_click()
|
||||
|
||||
# Should be open now
|
||||
assert has_element?(view, "ul#field-visibility-menu")
|
||||
end
|
||||
|
||||
test "displays all member fields in dropdown", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, view, _html} = live(conn, "/members")
|
||||
|
||||
# Open dropdown
|
||||
view
|
||||
|> element("button[aria-controls='field-visibility-menu']")
|
||||
|> render_click()
|
||||
|
||||
html = render(view)
|
||||
|
||||
# Check for member fields (formatted labels)
|
||||
assert html =~ "First Name" or html =~ "first_name"
|
||||
assert html =~ "Email" or html =~ "email"
|
||||
assert html =~ "Street" or html =~ "street"
|
||||
end
|
||||
|
||||
test "displays custom fields in dropdown", %{conn: conn, custom_field: custom_field} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, view, _html} = live(conn, "/members")
|
||||
|
||||
# Open dropdown
|
||||
view
|
||||
|> element("button[aria-controls='field-visibility-menu']")
|
||||
|> render_click()
|
||||
|
||||
html = render(view)
|
||||
|
||||
assert html =~ custom_field.name
|
||||
end
|
||||
end
|
||||
|
||||
describe "field visibility toggling" do
|
||||
test "hiding a field removes it from display", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, view, _html} = live(conn, "/members")
|
||||
|
||||
# Verify email is visible initially
|
||||
html = render(view)
|
||||
assert html =~ "alice@example.com"
|
||||
|
||||
# Open dropdown and hide email
|
||||
view
|
||||
|> element("button[aria-controls='field-visibility-menu']")
|
||||
|> render_click()
|
||||
|
||||
view
|
||||
|> element("button[phx-click='select_item'][phx-value-item='email']")
|
||||
|> render_click()
|
||||
|
||||
# Wait for update
|
||||
:timer.sleep(100)
|
||||
|
||||
# Email should no longer be visible
|
||||
html = render(view)
|
||||
refute html =~ "alice@example.com"
|
||||
refute html =~ "bob@example.com"
|
||||
end
|
||||
|
||||
test "showing a hidden field adds it to display", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
|
||||
# Start with only first_name and street explicitly set in URL
|
||||
# Note: Other fields may still be visible due to global settings
|
||||
{:ok, view, _html} = live(conn, "/members?fields=first_name,street")
|
||||
|
||||
# Verify first_name and street are visible
|
||||
html = render(view)
|
||||
assert html =~ "Alice"
|
||||
assert html =~ "Main St"
|
||||
|
||||
# Open dropdown and toggle email (to ensure it's visible)
|
||||
view
|
||||
|> element("button[aria-controls='field-visibility-menu']")
|
||||
|> render_click()
|
||||
|
||||
# If email is not visible, toggle it to make it visible
|
||||
# If it's already visible, toggle it off and on again
|
||||
view
|
||||
|> element("button[phx-click='select_item'][phx-value-item='email']")
|
||||
|> render_click()
|
||||
|
||||
# Wait for update
|
||||
:timer.sleep(100)
|
||||
|
||||
# Email should now be visible
|
||||
html = render(view)
|
||||
assert html =~ "alice@example.com"
|
||||
end
|
||||
|
||||
test "hiding custom field removes it from display", %{conn: conn, custom_field: custom_field} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, view, _html} = live(conn, "/members")
|
||||
|
||||
# Verify custom field is visible initially
|
||||
html = render(view)
|
||||
assert html =~ "M001" or html =~ custom_field.name
|
||||
|
||||
# Open dropdown and hide custom field
|
||||
view
|
||||
|> element("button[aria-controls='field-visibility-menu']")
|
||||
|> render_click()
|
||||
|
||||
custom_field_id = custom_field.id
|
||||
custom_field_string = "custom_field_#{custom_field_id}"
|
||||
|
||||
view
|
||||
|> element("button[phx-click='select_item'][phx-value-item='#{custom_field_string}']")
|
||||
|> render_click()
|
||||
|
||||
# Wait for update
|
||||
:timer.sleep(100)
|
||||
|
||||
# Custom field should no longer be visible
|
||||
html = render(view)
|
||||
refute html =~ "M001"
|
||||
refute html =~ "M002"
|
||||
end
|
||||
end
|
||||
|
||||
describe "select all / deselect all" do
|
||||
test "select all makes all fields visible", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
|
||||
# Start with some fields hidden
|
||||
{:ok, view, _html} = live(conn, "/members?fields=first_name")
|
||||
|
||||
# Open dropdown
|
||||
view
|
||||
|> element("button[aria-controls='field-visibility-menu']")
|
||||
|> render_click()
|
||||
|
||||
# Click select all
|
||||
view
|
||||
|> element("button[phx-click='select_all']")
|
||||
|> render_click()
|
||||
|
||||
# Wait for update
|
||||
:timer.sleep(100)
|
||||
|
||||
# All fields should be visible
|
||||
html = render(view)
|
||||
assert html =~ "alice@example.com"
|
||||
assert html =~ "Main St"
|
||||
assert html =~ "Berlin"
|
||||
end
|
||||
|
||||
test "deselect all hides all fields except first_name", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, view, _html} = live(conn, "/members")
|
||||
|
||||
# Open dropdown
|
||||
view
|
||||
|> element("button[aria-controls='field-visibility-menu']")
|
||||
|> render_click()
|
||||
|
||||
# Click deselect all
|
||||
view
|
||||
|> element("button[phx-click='select_none']")
|
||||
|> render_click()
|
||||
|
||||
# Wait for update
|
||||
:timer.sleep(100)
|
||||
|
||||
# Only first_name should be visible (it's always shown)
|
||||
html = render(view)
|
||||
# Email and street should be hidden
|
||||
refute html =~ "alice@example.com"
|
||||
refute html =~ "Main St"
|
||||
end
|
||||
end
|
||||
|
||||
describe "URL parameter persistence" do
|
||||
test "field selection is persisted in URL", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, view, _html} = live(conn, "/members")
|
||||
|
||||
# Open dropdown and hide email
|
||||
view
|
||||
|> element("button[aria-controls='field-visibility-menu']")
|
||||
|> render_click()
|
||||
|
||||
view
|
||||
|> element("button[phx-click='select_item'][phx-value-item='email']")
|
||||
|> render_click()
|
||||
|
||||
# Wait for URL update
|
||||
:timer.sleep(100)
|
||||
|
||||
# Check that URL contains fields parameter
|
||||
# Note: In LiveView tests, we check the rendered HTML for the updated state
|
||||
# The actual URL update happens via push_patch
|
||||
end
|
||||
|
||||
test "loading page with fields parameter applies selection", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
|
||||
# Load with first_name and city explicitly set in URL
|
||||
# Note: Other fields may still be visible due to global settings
|
||||
{:ok, view, _html} = live(conn, "/members?fields=first_name,city")
|
||||
|
||||
html = render(view)
|
||||
|
||||
# first_name and city should be visible
|
||||
assert html =~ "Alice"
|
||||
assert html =~ "Berlin"
|
||||
|
||||
# Note: email and street may still be visible if global settings allow it
|
||||
# This test verifies that the URL parameters work, not that they hide other fields
|
||||
end
|
||||
|
||||
test "fields parameter works with custom fields", %{conn: conn, custom_field: custom_field} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
custom_field_id = custom_field.id
|
||||
|
||||
# Load with custom field visible
|
||||
{:ok, view, _html} =
|
||||
live(conn, "/members?fields=first_name,custom_field_#{custom_field_id}")
|
||||
|
||||
html = render(view)
|
||||
|
||||
# Custom field should be visible
|
||||
assert html =~ "M001" or html =~ custom_field.name
|
||||
end
|
||||
end
|
||||
|
||||
describe "integration with global settings" do
|
||||
test "respects global settings when no user selection", %{conn: conn} do
|
||||
# This test would require setting up global settings
|
||||
# For now, we verify that the system works with default settings
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, _view, html} = live(conn, "/members")
|
||||
|
||||
# All fields should be visible by default
|
||||
assert html =~ "alice@example.com"
|
||||
assert html =~ "Main St"
|
||||
end
|
||||
|
||||
test "user selection overrides global settings", %{conn: conn} do
|
||||
# This would require setting up global settings first
|
||||
# Then verifying that user selection takes precedence
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, view, _html} = live(conn, "/members")
|
||||
|
||||
# Hide a field via dropdown
|
||||
view
|
||||
|> element("button[aria-controls='field-visibility-menu']")
|
||||
|> render_click()
|
||||
|
||||
view
|
||||
|> element("button[phx-click='select_item'][phx-value-item='email']")
|
||||
|> render_click()
|
||||
|
||||
:timer.sleep(100)
|
||||
|
||||
html = render(view)
|
||||
refute html =~ "alice@example.com"
|
||||
end
|
||||
end
|
||||
|
||||
describe "edge cases" do
|
||||
test "handles empty fields parameter", %{conn: conn} 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"
|
||||
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"
|
||||
end
|
||||
|
||||
test "handles custom field that doesn't exist", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, _view, html} = live(conn, "/members?fields=first_name,custom_field_nonexistent")
|
||||
|
||||
# Should work without errors
|
||||
assert html =~ "Alice"
|
||||
end
|
||||
|
||||
test "handles rapid toggling", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, view, _html} = live(conn, "/members")
|
||||
|
||||
# Open dropdown
|
||||
view
|
||||
|> element("button[aria-controls='field-visibility-menu']")
|
||||
|> render_click()
|
||||
|
||||
# Rapidly toggle a field multiple times
|
||||
for _ <- 1..5 do
|
||||
view
|
||||
|> element("button[phx-click='select_item'][phx-value-item='email']")
|
||||
|> render_click()
|
||||
|
||||
:timer.sleep(50)
|
||||
end
|
||||
|
||||
# Should still work correctly
|
||||
html = render(view)
|
||||
assert html =~ "Alice"
|
||||
end
|
||||
end
|
||||
|
||||
describe "accessibility" do
|
||||
test "dropdown has proper ARIA attributes", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, _view, html} = live(conn, "/members")
|
||||
|
||||
assert html =~ ~s(aria-controls="field-visibility-menu")
|
||||
assert html =~ ~s(aria-haspopup="menu")
|
||||
assert html =~ ~s(role="button")
|
||||
end
|
||||
|
||||
test "menu items have proper ARIA attributes when open", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, view, _html} = live(conn, "/members")
|
||||
|
||||
# Open dropdown
|
||||
view
|
||||
|> element("button[aria-controls='field-visibility-menu']")
|
||||
|> render_click()
|
||||
|
||||
html = render(view)
|
||||
|
||||
assert html =~ ~s(role="menu")
|
||||
assert html =~ ~s(role="menuitemcheckbox")
|
||||
assert html =~ ~s(aria-checked)
|
||||
end
|
||||
|
||||
test "keyboard navigation works", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, view, _html} = live(conn, "/members")
|
||||
|
||||
# Open dropdown
|
||||
view
|
||||
|> element("button[aria-controls='field-visibility-menu']")
|
||||
|> render_click()
|
||||
|
||||
# Check that elements are keyboard accessible
|
||||
html = render(view)
|
||||
assert html =~ ~s(tabindex="0")
|
||||
# Check that keyboard events are supported
|
||||
assert html =~ ~s(phx-keydown="select_item")
|
||||
assert html =~ ~s(phx-key="Enter Space")
|
||||
end
|
||||
|
||||
test "keyboard activation with Enter key works", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, view, _html} = live(conn, "/members")
|
||||
|
||||
# Verify email is visible initially
|
||||
html = render(view)
|
||||
assert html =~ "alice@example.com"
|
||||
|
||||
# Open dropdown
|
||||
view
|
||||
|> element("button[aria-controls='field-visibility-menu']")
|
||||
|> render_click()
|
||||
|
||||
# Simulate Enter key press on email field button
|
||||
view
|
||||
|> element("button[phx-click='select_item'][phx-value-item='email']")
|
||||
|> render_keydown("Enter")
|
||||
|
||||
# Wait for update
|
||||
:timer.sleep(100)
|
||||
|
||||
# Email should no longer be visible
|
||||
html = render(view)
|
||||
refute html =~ "alice@example.com"
|
||||
end
|
||||
|
||||
test "keyboard activation with Space key works", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, view, _html} = live(conn, "/members")
|
||||
|
||||
# Verify email is visible initially
|
||||
html = render(view)
|
||||
assert html =~ "alice@example.com"
|
||||
|
||||
# Open dropdown
|
||||
view
|
||||
|> element("button[aria-controls='field-visibility-menu']")
|
||||
|> render_click()
|
||||
|
||||
# Simulate Space key press on email field button
|
||||
view
|
||||
|> element("button[phx-click='select_item'][phx-value-item='email']")
|
||||
|> render_keydown(" ")
|
||||
|
||||
# Wait for update
|
||||
:timer.sleep(100)
|
||||
|
||||
# Email should no longer be visible
|
||||
html = render(view)
|
||||
refute html =~ "alice@example.com"
|
||||
end
|
||||
end
|
||||
end
|
||||
64
test/mv_web/member_live/index_member_fields_display_test.exs
Normal file
64
test/mv_web/member_live/index_member_fields_display_test.exs
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
defmodule MvWeb.MemberLive.IndexMemberFieldsDisplayTest do
|
||||
use MvWeb.ConnCase, async: true
|
||||
import Phoenix.LiveViewTest
|
||||
require Ash.Query
|
||||
|
||||
alias Mv.Membership.Member
|
||||
|
||||
setup do
|
||||
{:ok, member1} =
|
||||
Member
|
||||
|> Ash.Changeset.for_create(:create_member, %{
|
||||
first_name: "Alice",
|
||||
last_name: "Anderson",
|
||||
email: "alice@example.com",
|
||||
street: "Main Street",
|
||||
house_number: "123",
|
||||
postal_code: "12345",
|
||||
city: "Berlin",
|
||||
phone_number: "+49123456789",
|
||||
join_date: ~D[2020-01-15]
|
||||
})
|
||||
|> Ash.create()
|
||||
|
||||
{:ok, member2} =
|
||||
Member
|
||||
|> Ash.Changeset.for_create(:create_member, %{
|
||||
first_name: "Bob",
|
||||
last_name: "Brown",
|
||||
email: "bob@example.com"
|
||||
})
|
||||
|> Ash.create()
|
||||
|
||||
%{
|
||||
member1: member1,
|
||||
member2: member2
|
||||
}
|
||||
end
|
||||
|
||||
test "shows multiple members correctly", %{conn: conn, member1: m1, member2: m2} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, _view, html} = live(conn, "/members")
|
||||
|
||||
for m <- [m1, m2], field <- [m.first_name, m.last_name, m.email] do
|
||||
assert html =~ field
|
||||
end
|
||||
end
|
||||
|
||||
test "respects show_in_overview config", %{conn: conn, member1: m} do
|
||||
{:ok, settings} = Mv.Membership.get_settings()
|
||||
fields_to_hide = [:street, :house_number]
|
||||
|
||||
{:ok, _} =
|
||||
Mv.Membership.update_settings(settings, %{
|
||||
member_field_visibility: Map.new(fields_to_hide, &{&1, false})
|
||||
})
|
||||
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, _view, html} = live(conn, "/members")
|
||||
|
||||
assert html =~ "Email"
|
||||
assert html =~ m.email
|
||||
refute html =~ m.street
|
||||
end
|
||||
end
|
||||
Loading…
Add table
Add a link
Reference in a new issue