Compare commits
30 commits
9de0063fdf
...
603a972b9c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
603a972b9c | ||
| 702eebd110 | |||
| 5ae4450444 | |||
| cf6a108049 | |||
| fabfe64468 | |||
| 6029920c3f | |||
| 6cf955b024 | |||
| 217ed632fa | |||
| 3b038d451d | |||
| ecc6522571 | |||
| b9bd5882e7 | |||
| 690083bdf0 | |||
| 4bbba65038 | |||
| 75e1fc8a3a | |||
| f5b67de870 | |||
| c17445975c | |||
| c9678231f9 | |||
| c3b33b55a5 | |||
| 8d1d04fa05 | |||
| 064c0df701 | |||
| f0613fe1e5 | |||
| 206e733511 | |||
| 0fb43a0816 | |||
| 45a9bc0cc0 | |||
| d039e4bb7d | |||
| 7f0da693ee | |||
| 82e41916d2 | |||
| a022d8cd02 | |||
| f24d4985fc | |||
| cf957563bb |
21 changed files with 2482 additions and 91 deletions
3
Justfile
3
Justfile
|
|
@ -1,4 +1,7 @@
|
|||
set dotenv-load := true
|
||||
set export := true
|
||||
|
||||
MIX_QUIET := "1"
|
||||
|
||||
run: install-dependencies start-database migrate-database seed-database
|
||||
mix phx.server
|
||||
|
|
|
|||
|
|
@ -401,6 +401,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.
|
||||
|
||||
|
|
|
|||
|
|
@ -18,5 +18,17 @@ defmodule Mv.Constants do
|
|||
:postal_code
|
||||
]
|
||||
|
||||
@custom_field_prefix "custom_field_"
|
||||
|
||||
def member_fields, do: @member_fields
|
||||
|
||||
@doc """
|
||||
Returns the prefix used for custom field keys in field visibility maps.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> Mv.Constants.custom_field_prefix()
|
||||
"custom_field_"
|
||||
"""
|
||||
def custom_field_prefix, do: @custom_field_prefix
|
||||
end
|
||||
|
|
|
|||
|
|
@ -119,6 +119,123 @@ 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, required: true, doc: "The LiveView/LiveComponent target for events"
|
||||
|
||||
def dropdown_menu(assigns) do
|
||||
~H"""
|
||||
<div
|
||||
class="relative"
|
||||
phx-click-away="close_dropdown"
|
||||
phx-target={@phx_target}
|
||||
phx-window-keydown="close_dropdown"
|
||||
phx-key="Escape"
|
||||
data-testid="dropdown-menu"
|
||||
>
|
||||
<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}
|
||||
data-testid="dropdown-button"
|
||||
>
|
||||
<%= 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-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">
|
||||
<button
|
||||
type="button"
|
||||
role={if @checkboxes, do: "menuitemcheckbox", else: "menuitem"}
|
||||
aria-checked={
|
||||
if @checkboxes, do: to_string(Map.get(@selected, item.value, true)), else: nil
|
||||
}
|
||||
tabindex="0"
|
||||
class="flex items-center gap-2 px-2 py-1 rounded cursor-pointer hover:bg-base-200 w-full text-left"
|
||||
phx-click="select_item"
|
||||
phx-keydown="select_item"
|
||||
phx-key="Enter"
|
||||
phx-value-item={item.value}
|
||||
phx-target={@phx_target}
|
||||
>
|
||||
<%= if @checkboxes do %>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={Map.get(@selected, item.value, true)}
|
||||
class="checkbox checkbox-sm checkbox-primary"
|
||||
tabindex="-1"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<% end %>
|
||||
<span>{item.label}</span>
|
||||
</button>
|
||||
</li>
|
||||
<% end %>
|
||||
</ul>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
@doc """
|
||||
Renders an input with label and error messages.
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,176 @@
|
|||
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(extract_member_field_keys(all_fields), fn field ->
|
||||
%{
|
||||
value: field_to_string(field),
|
||||
label: format_field_label(field)
|
||||
}
|
||||
end) ++
|
||||
Enum.map(extract_custom_field_keys(all_fields), fn field ->
|
||||
%{
|
||||
value: field,
|
||||
label: format_custom_field_label(field, custom_fields)
|
||||
}
|
||||
end)
|
||||
|
||||
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 extract_member_field_keys(nil), do: []
|
||||
|
||||
defp extract_member_field_keys(fields) do
|
||||
prefix = Mv.Constants.custom_field_prefix()
|
||||
|
||||
Enum.filter(fields, fn field ->
|
||||
is_atom(field) ||
|
||||
(is_binary(field) && not String.starts_with?(field, prefix))
|
||||
end)
|
||||
end
|
||||
|
||||
defp extract_custom_field_keys(nil), do: []
|
||||
|
||||
defp extract_custom_field_keys(fields) do
|
||||
prefix = Mv.Constants.custom_field_prefix()
|
||||
|
||||
Enum.filter(fields, fn field ->
|
||||
is_binary(field) && String.starts_with?(field, prefix)
|
||||
end)
|
||||
end
|
||||
|
||||
defp field_to_string(field) when is_atom(field), do: Atom.to_string(field)
|
||||
defp field_to_string(field) when is_binary(field), do: field
|
||||
|
||||
defp format_field_label(field) do
|
||||
field
|
||||
|> field_to_string()
|
||||
|> String.replace("_", " ")
|
||||
|> String.split()
|
||||
|> Enum.map_join(" ", &String.capitalize/1)
|
||||
end
|
||||
|
||||
defp format_custom_field_label(field_string, custom_fields) do
|
||||
id = String.trim_leading(field_string, Mv.Constants.custom_field_prefix())
|
||||
find_custom_field_name(id, field_string, custom_fields)
|
||||
end
|
||||
|
||||
defp find_custom_field_name("", field_string, _custom_fields), do: field_string
|
||||
|
||||
defp find_custom_field_name(id, _field_string, custom_fields) do
|
||||
case Enum.find(custom_fields, fn cf -> to_string(cf.id) == id end) do
|
||||
nil -> gettext("Custom Field %{id}", id: id)
|
||||
custom_field -> custom_field.name
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -33,9 +33,11 @@ defmodule MvWeb.MemberLive.Index do
|
|||
alias Mv.Membership
|
||||
alias MvWeb.MemberLive.Index.Formatter
|
||||
alias MvWeb.Helpers.DateFormatter
|
||||
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_"
|
||||
@custom_field_prefix Mv.Constants.custom_field_prefix()
|
||||
|
||||
# Member fields that are loaded for the overview
|
||||
# Uses constants from Mv.Constants to ensure consistency
|
||||
|
|
@ -50,8 +52,8 @@ defmodule MvWeb.MemberLive.Index do
|
|||
payment filter, 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.
|
||||
|
|
@ -61,6 +63,12 @@ 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
|
||||
|
|
@ -69,6 +77,20 @@ defmodule MvWeb.MemberLive.Index do
|
|||
{: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"))
|
||||
|
|
@ -77,8 +99,15 @@ defmodule MvWeb.MemberLive.Index do
|
|||
|> assign_new(:sort_order, fn -> :asc end)
|
||||
|> assign(:paid_filter, nil)
|
||||
|> assign(:selected_members, MapSet.new())
|
||||
|> assign(:settings, settings)
|
||||
|> assign(:custom_fields_visible, custom_fields_visible)
|
||||
|> assign(:member_fields_visible, get_visible_member_fields(settings))
|
||||
|> assign(:all_custom_fields, all_custom_fields)
|
||||
|> assign(:all_available_fields, all_available_fields)
|
||||
|> assign(:user_field_selection, initial_selection)
|
||||
|> assign(
|
||||
:member_fields_visible,
|
||||
FieldVisibility.get_visible_member_fields(initial_selection)
|
||||
)
|
||||
|
||||
# We call handle params to use the query from the URL
|
||||
{:ok, socket}
|
||||
|
|
@ -183,6 +212,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
|
||||
|
|
@ -251,24 +282,111 @@ 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 (use all_custom_fields to allow enabling globally hidden fields)
|
||||
final_selection =
|
||||
FieldVisibility.merge_with_global_settings(
|
||||
new_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()
|
||||
|> 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()
|
||||
|> 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, sort order, and payment filter,
|
||||
Parses query parameters for search query, sort field, sort order, and payment filter, 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)
|
||||
|> maybe_update_paid_filter(params)
|
||||
|> assign(:query, params["query"])
|
||||
|> 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()
|
||||
|> prepare_dynamic_cols()
|
||||
|
||||
|
|
@ -281,10 +399,17 @@ 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] || []
|
||||
|
||||
# Use all_custom_fields to allow users to enable globally hidden custom fields
|
||||
dynamic_cols =
|
||||
Enum.map(socket.assigns.custom_fields_visible, fn custom_field ->
|
||||
socket.assigns.all_custom_fields
|
||||
|> 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 ->
|
||||
|
|
@ -377,6 +502,58 @@ 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
|
||||
base_params = %{
|
||||
"sort_field" => field_to_string(socket.assigns.sort_field),
|
||||
"sort_order" => Atom.to_string(socket.assigns.sort_order)
|
||||
}
|
||||
|
||||
# Include paid_filter if set
|
||||
base_params =
|
||||
case socket.assigns.paid_filter do
|
||||
nil -> base_params
|
||||
:paid -> Map.put(base_params, "paid_filter", "paid")
|
||||
:not_paid -> Map.put(base_params, "paid_filter", "not_paid")
|
||||
end
|
||||
|
||||
query_params = build_query_params(socket, base_params)
|
||||
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
|
||||
|
||||
# Builds URL query parameters map including all filter/sort state.
|
||||
# Converts paid_filter atom to string for URL.
|
||||
defp build_query_params(query, sort_field, sort_order, paid_filter) do
|
||||
|
|
@ -435,9 +612,9 @@ defmodule MvWeb.MemberLive.Index do
|
|||
|> Ash.Query.new()
|
||||
|> 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)
|
||||
|
|
@ -615,6 +792,18 @@ defmodule MvWeb.MemberLive.Index do
|
|||
|
||||
defp extract_custom_field_id(_), do: nil
|
||||
|
||||
# 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_prefix) do
|
||||
["", id] -> id
|
||||
_ -> nil
|
||||
end
|
||||
end)
|
||||
|> Enum.filter(&(&1 != nil))
|
||||
end
|
||||
|
||||
# Sorts members in memory by a custom field value.
|
||||
#
|
||||
# Process:
|
||||
|
|
@ -911,34 +1100,6 @@ defmodule MvWeb.MemberLive.Index do
|
|||
end
|
||||
end
|
||||
|
||||
# Gets the list of member fields that should be visible in the overview.
|
||||
#
|
||||
# Reads the visibility configuration from Settings and returns only the fields
|
||||
# where show_in_overview is true. 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 list of atoms representing visible member field names.
|
||||
#
|
||||
# Fields are read from the global Constants module.
|
||||
@spec get_visible_member_fields(map()) :: [atom()]
|
||||
defp get_visible_member_fields(settings) do
|
||||
# Get all eligible fields from the global constants
|
||||
all_fields = Mv.Constants.member_fields()
|
||||
|
||||
# JSONB stores keys as strings
|
||||
visibility_config = settings.member_field_visibility || %{}
|
||||
|
||||
# Filter to only return visible fields
|
||||
Enum.filter(all_fields, fn field ->
|
||||
Map.get(visibility_config, Atom.to_string(field), true)
|
||||
end)
|
||||
end
|
||||
|
||||
# Public helper function to format dates for use in templates
|
||||
def format_date(date), do: DateFormatter.format_date(date)
|
||||
end
|
||||
|
|
|
|||
|
|
@ -44,6 +44,13 @@
|
|||
paid_filter={@paid_filter}
|
||||
member_count={length(@members)}
|
||||
/>
|
||||
<.live_component
|
||||
module={MvWeb.Components.FieldVisibilityDropdownComponent}
|
||||
id="field-visibility-dropdown"
|
||||
all_fields={@all_available_fields}
|
||||
custom_fields={@all_custom_fields}
|
||||
selected_fields={@user_field_selection}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<.table
|
||||
|
|
@ -85,6 +92,7 @@
|
|||
</:col>
|
||||
<:col
|
||||
:let={member}
|
||||
:if={:first_name in @member_fields_visible}
|
||||
label={
|
||||
~H"""
|
||||
<.live_component
|
||||
|
|
@ -98,7 +106,25 @@
|
|||
"""
|
||||
}
|
||||
>
|
||||
{member.first_name} {member.last_name}
|
||||
{member.first_name}
|
||||
</:col>
|
||||
<:col
|
||||
:let={member}
|
||||
:if={:last_name in @member_fields_visible}
|
||||
label={
|
||||
~H"""
|
||||
<.live_component
|
||||
module={MvWeb.Components.SortHeaderComponent}
|
||||
id={:sort_last_name}
|
||||
field={:last_name}
|
||||
label={gettext("Last name")}
|
||||
sort_field={@sort_field}
|
||||
sort_order={@sort_order}
|
||||
/>
|
||||
"""
|
||||
}
|
||||
>
|
||||
{member.last_name}
|
||||
</:col>
|
||||
<:col
|
||||
:let={member}
|
||||
|
|
@ -226,7 +252,7 @@
|
|||
>
|
||||
{MvWeb.MemberLive.Index.format_date(member.join_date)}
|
||||
</:col>
|
||||
<:col :let={member} label={gettext("Paid")}>
|
||||
<:col :let={member} :if={:paid in @member_fields_visible} label={gettext("Paid")}>
|
||||
<span class={[
|
||||
"badge",
|
||||
if(member.paid == true, do: "badge-success", else: "badge-error")
|
||||
|
|
|
|||
231
lib/mv_web/live/member_live/index/field_selection.ex
Normal file
231
lib/mv_web/live/member_live/index/field_selection.ex
Normal file
|
|
@ -0,0 +1,231 @@
|
|||
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 parses the raw Cookie header. 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
|
||||
# get_req_header always returns a list ([] if no header, [value] if present)
|
||||
case Plug.Conn.get_req_header(conn, "cookie") do
|
||||
[] ->
|
||||
%{}
|
||||
|
||||
[cookie_header | _rest] ->
|
||||
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_join(",", fn {field, _visible} -> field end)
|
||||
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}, acc when is_boolean(value) -> Map.put(acc, key, value)
|
||||
{key, _value}, acc -> Map.put(acc, 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
|
||||
239
lib/mv_web/live/member_live/index/field_visibility.ex
Normal file
239
lib/mv_web/live/member_live/index/field_visibility.ex
Normal file
|
|
@ -0,0 +1,239 @@
|
|||
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
|
||||
prefix = Mv.Constants.custom_field_prefix()
|
||||
|
||||
field_selection
|
||||
|> Enum.filter(fn {field_string, visible} ->
|
||||
visible && String.starts_with?(field_string, prefix)
|
||||
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
|
||||
prefix = Mv.Constants.custom_field_prefix()
|
||||
|
||||
Enum.reduce(custom_fields, %{}, fn custom_field, acc ->
|
||||
field_string = "#{prefix}#{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, Mv.Constants.custom_field_prefix()) 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
|
||||
4
mix.exs
4
mix.exs
|
|
@ -38,7 +38,7 @@ defmodule Mv.MixProject do
|
|||
[
|
||||
{:tidewave, "~> 0.5", only: [:dev]},
|
||||
{:sourceror, "~> 1.8", only: [:dev, :test]},
|
||||
{:live_debugger, "~> 0.4", only: [:dev]},
|
||||
{:live_debugger, "~> 0.5", only: [:dev]},
|
||||
{:ash_admin, "~> 0.13"},
|
||||
{:ash_postgres, "~> 2.0"},
|
||||
{:ash_phoenix, "~> 2.0"},
|
||||
|
|
@ -46,7 +46,7 @@ defmodule Mv.MixProject do
|
|||
{:bcrypt_elixir, "~> 3.0"},
|
||||
{:ash_authentication, "~> 4.9"},
|
||||
{:ash_authentication_phoenix, "~> 2.10"},
|
||||
{:igniter, "~> 0.6", only: [:dev, :test]},
|
||||
{:igniter, "~> 0.7", only: [:dev, :test]},
|
||||
{:phoenix, "~> 1.8.0-rc.4", override: true},
|
||||
{:phoenix_ecto, "~> 4.5"},
|
||||
{:ecto_sql, "~> 3.10"},
|
||||
|
|
|
|||
48
mix.lock
48
mix.lock
|
|
@ -1,32 +1,32 @@
|
|||
%{
|
||||
"ash": {:hex, :ash, "3.7.1", "abb55dee19e0959e529e52fe0622468825ae05400f535484919713e492d9a9e7", [:mix], [{:crux, "~> 0.1.0", [hex: :crux, repo: "hexpm", optional: false]}, {:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.7", [hex: :ecto, repo: "hexpm", optional: false]}, {:ets, "~> 0.8", [hex: :ets, repo: "hexpm", optional: false]}, {:igniter, ">= 0.6.29 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: false]}, {:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}, {:plug, ">= 0.0.0", [hex: :plug, repo: "hexpm", optional: true]}, {:reactor, "~> 0.11", [hex: :reactor, repo: "hexpm", optional: false]}, {:simple_sat, ">= 0.1.1 and < 1.0.0-0", [hex: :simple_sat, repo: "hexpm", optional: true]}, {:spark, ">= 2.3.3 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, ">= 0.2.6 and < 1.0.0-0", [hex: :splode, repo: "hexpm", optional: false]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.1", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "4474ce9befe9862d1ed73cadf8a755e836c45a14a7b3b952d02e1a12f2b2e529"},
|
||||
"ash_admin": {:hex, :ash_admin, "0.13.19", "43227905381ea0b835039fb3f3d255a3664925619937869e605402bc2f95c5e5", [:mix], [{:ash, ">= 3.4.63 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_phoenix, ">= 2.1.8 and < 3.0.0-0", [hex: :ash_phoenix, repo: "hexpm", optional: false]}, {:gettext, "~> 0.26", [hex: :gettext, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.7", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.1-rc", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}], "hexpm", "41e6262c437164df6f052e43cc93be225a7e148b49a813fc451e70172338ee38"},
|
||||
"ash_authentication": {:hex, :ash_authentication, "4.11.0", "4165ede37e179cb0a24b7bfc38d620fa93c05fb6272fbd353cafe27652b1e68b", [:mix], [{:argon2_elixir, "~> 4.0", [hex: :argon2_elixir, repo: "hexpm", optional: true]}, {:ash, ">= 3.4.29 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_postgres, ">= 2.6.8 and < 3.0.0-0", [hex: :ash_postgres, repo: "hexpm", optional: true]}, {:assent, "~> 0.2.13", [hex: :assent, repo: "hexpm", optional: false]}, {:bcrypt_elixir, "~> 3.0", [hex: :bcrypt_elixir, repo: "hexpm", optional: false]}, {:castore, "~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:finch, "~> 0.19", [hex: :finch, repo: "hexpm", optional: false]}, {:igniter, "~> 0.4", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:joken, "~> 2.5", [hex: :joken, repo: "hexpm", optional: false]}, {:plug, "~> 1.13", [hex: :plug, repo: "hexpm", optional: false]}, {:spark, "~> 2.0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.2", [hex: :splode, repo: "hexpm", optional: false]}], "hexpm", "8201d0169944c1df3db9b560494e50e1c3bc99c3b1a8a2ef1e61b0f77bc820df"},
|
||||
"ash_authentication_phoenix": {:hex, :ash_authentication_phoenix, "2.12.0", "75d7d77e3b626f3d8ea6ee44291d885950172ab399d997b2934f93d2e0a55a61", [:mix], [{:ash, "~> 3.0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_authentication, "~> 4.10", [hex: :ash_authentication, repo: "hexpm", optional: false]}, {:ash_phoenix, ">= 2.3.11 and < 3.0.0-0", [hex: :ash_phoenix, repo: "hexpm", optional: false]}, {:bcrypt_elixir, "~> 3.0", [hex: :bcrypt_elixir, repo: "hexpm", optional: false]}, {:gettext, "~> 0.26 or ~> 1.0", [hex: :gettext, repo: "hexpm", optional: true]}, {:igniter, ">= 0.5.25 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.6", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_html_helpers, "~> 1.0", [hex: :phoenix_html_helpers, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}, {:slugify, "~> 1.3", [hex: :slugify, repo: "hexpm", optional: false]}], "hexpm", "a423e22b40fdf3b1a7f2178e44ca68f48fdb5ba0d87e8d42a43de1a3b63ca704"},
|
||||
"ash_phoenix": {:hex, :ash_phoenix, "2.3.17", "a074ae6d9d7135d99c4edc91ddebe4c035ca380b044592bf9c3d58471669cf52", [:mix], [{:ash, ">= 3.5.13 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:igniter, "~> 0.6", [hex: :igniter, repo: "hexpm", optional: true]}, {:inertia, "~> 2.3", [hex: :inertia, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.5.6 or ~> 1.6", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.20.3 or ~> 1.0-rc.1", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:spark, ">= 2.2.29 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}], "hexpm", "94e4a6cc6ced31cddba930c45c1c3477aa59b956e7fc3cdc63095cf0e506bdf5"},
|
||||
"ash_postgres": {:hex, :ash_postgres, "2.6.23", "5976a7e5e204b7bc627b1d17026bec9da4d880f2e09cd94bf4e8cee41fef32ce", [:mix], [{:ash, "~> 3.7", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_sql, ">= 0.3.7 and < 1.0.0-0", [hex: :ash_sql, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.13", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:igniter, ">= 0.6.29 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:postgrex, ">= 0.0.0", [hex: :postgrex, repo: "hexpm", optional: false]}, {:spark, ">= 2.3.4 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}], "hexpm", "61de4aedfe30f1ae14d8185cfc37a5b1940b45b60f2dfbdf9eb056f97dca41c5"},
|
||||
"ash_sql": {:hex, :ash_sql, "0.3.7", "80affa5446075d71deb157c67290685a84b392d723be766bfb684f58fe0143de", [:mix], [{:ash, "~> 3.7", [hex: :ash, repo: "hexpm", optional: false]}, {:ecto, "~> 3.9", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.9", [hex: :ecto_sql, repo: "hexpm", optional: false]}], "hexpm", "ce4d974b8e784171c5a2a62593b3672b42dfd4888fa2239f01a6b32bad769038"},
|
||||
"ash": {:hex, :ash, "3.10.1", "e0a9cd71d439563734bbaf1580bdc201866e8597a8e1f0711b5140b9694a020f", [:mix], [{:crux, ">= 0.1.2 and < 1.0.0-0", [hex: :crux, repo: "hexpm", optional: false]}, {:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.7", [hex: :ecto, repo: "hexpm", optional: false]}, {:ets, "~> 0.8", [hex: :ets, repo: "hexpm", optional: false]}, {:igniter, ">= 0.6.29 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: false]}, {:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}, {:plug, ">= 0.0.0", [hex: :plug, repo: "hexpm", optional: true]}, {:reactor, "~> 0.11", [hex: :reactor, repo: "hexpm", optional: false]}, {:simple_sat, ">= 0.1.1 and < 1.0.0-0", [hex: :simple_sat, repo: "hexpm", optional: true]}, {:spark, ">= 2.3.14 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, ">= 0.2.6 and < 1.0.0-0", [hex: :splode, repo: "hexpm", optional: false]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.1", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "ac55578b6208f420ae02fe259b609dd274a0b5193a56a22f73c5dbb0db40bd8b"},
|
||||
"ash_admin": {:hex, :ash_admin, "0.13.23", "09f25429727b7c3313006a5c28a5e95d3f80f10989461f69ae3c46f01233aa1d", [:mix], [{:ash, ">= 3.4.63 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_phoenix, ">= 2.1.8 and < 3.0.0-0", [hex: :ash_phoenix, repo: "hexpm", optional: false]}, {:gettext, "~> 0.26 or ~> 1.0", [hex: :gettext, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.7", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.1-rc", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}], "hexpm", "4d06e002313591e354ab25b4c82090261783fd8b50c773baea9f8a8ad370b834"},
|
||||
"ash_authentication": {:hex, :ash_authentication, "4.13.3", "4d7a2e96b5a8fe68797ba0124cf40e6897c82b9fb69182fc5fdaac529b72d436", [:mix], [{:argon2_elixir, "~> 4.0", [hex: :argon2_elixir, repo: "hexpm", optional: true]}, {:ash, "~> 3.7", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_postgres, ">= 2.6.8 and < 3.0.0-0", [hex: :ash_postgres, repo: "hexpm", optional: true]}, {:assent, "> 0.2.0 and < 0.3.0", [hex: :assent, repo: "hexpm", optional: false]}, {:bcrypt_elixir, "~> 3.0", [hex: :bcrypt_elixir, repo: "hexpm", optional: false]}, {:castore, "~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:finch, "~> 0.19", [hex: :finch, repo: "hexpm", optional: false]}, {:igniter, "~> 0.4", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:joken, "~> 2.5", [hex: :joken, repo: "hexpm", optional: false]}, {:plug, "~> 1.13", [hex: :plug, repo: "hexpm", optional: false]}, {:spark, "~> 2.0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.2", [hex: :splode, repo: "hexpm", optional: false]}], "hexpm", "03d95b68766b28cda241e68217f6d1d839be350f7e8f20923162b163fb521b91"},
|
||||
"ash_authentication_phoenix": {:hex, :ash_authentication_phoenix, "2.12.2", "a4646498a7e21fbdbe372f0d8afab08b5d7125b629f91bfcf8f4d1961bc9d57b", [:mix], [{:ash, "~> 3.0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_authentication, "~> 4.10", [hex: :ash_authentication, repo: "hexpm", optional: false]}, {:ash_phoenix, ">= 2.3.11 and < 3.0.0-0", [hex: :ash_phoenix, repo: "hexpm", optional: false]}, {:bcrypt_elixir, "~> 3.0", [hex: :bcrypt_elixir, repo: "hexpm", optional: false]}, {:gettext, "~> 0.26 or ~> 1.0", [hex: :gettext, repo: "hexpm", optional: true]}, {:igniter, ">= 0.5.25 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.6", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_html_helpers, "~> 1.0", [hex: :phoenix_html_helpers, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.1", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}, {:slugify, "~> 1.3", [hex: :slugify, repo: "hexpm", optional: false]}], "hexpm", "1dd6fa3a8f7d2563a53cf22aeda31770c855e927421af4d8bfaf480332acf721"},
|
||||
"ash_phoenix": {:hex, :ash_phoenix, "2.3.18", "fad1b8af1405758888086de568f08650c2911ee97074cfe2e325b14854bc43dd", [:mix], [{:ash, ">= 3.5.13 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:igniter, "~> 0.6", [hex: :igniter, repo: "hexpm", optional: true]}, {:inertia, "~> 2.3", [hex: :inertia, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.5.6 or ~> 1.6", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.20.3 or ~> 1.0-rc.1", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:spark, ">= 2.2.29 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}], "hexpm", "7ec28f9216221e83b90d9c3605e9d1cdd228984e09a1a86c9b9d393cebf25222"},
|
||||
"ash_postgres": {:hex, :ash_postgres, "2.6.26", "f995bac8762ae039d4fb94cf2b628430aa69b0b30bf4366b96b3543dbd679ae7", [:mix], [{:ash, "~> 3.9", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_sql, ">= 0.3.12 and < 1.0.0-0", [hex: :ash_sql, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.13", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:igniter, ">= 0.6.29 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:postgrex, ">= 0.0.0", [hex: :postgrex, repo: "hexpm", optional: false]}, {:spark, ">= 2.3.4 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}], "hexpm", "7050b3169d5a31d73f7e69a6564d1102cb2bc185e67ea428e78fda3da46a69fc"},
|
||||
"ash_sql": {:hex, :ash_sql, "0.3.15", "8b8daae1870ab37b4fb2f980e323194caf23cdb4218fef126c49cc11a01fa243", [:mix], [{:ash, "~> 3.7", [hex: :ash, repo: "hexpm", optional: false]}, {:ecto, ">= 3.13.4 and < 4.0.0-0", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.9", [hex: :ecto_sql, repo: "hexpm", optional: false]}], "hexpm", "97432507b6f406eb2461e5d0fbf2e5104a8c61a2570322d11de2f124d822d8ff"},
|
||||
"assent": {:hex, :assent, "0.2.13", "11226365d2d8661d23e9a2cf94d3255e81054ff9d88ac877f28bfdf38fa4ef31", [:mix], [{:certifi, ">= 0.0.0", [hex: :certifi, repo: "hexpm", optional: true]}, {:finch, "~> 0.15", [hex: :finch, repo: "hexpm", optional: true]}, {:jose, "~> 1.8", [hex: :jose, repo: "hexpm", optional: true]}, {:mint, "~> 1.0", [hex: :mint, repo: "hexpm", optional: true]}, {:req, "~> 0.4", [hex: :req, repo: "hexpm", optional: true]}, {:ssl_verify_fun, ">= 0.0.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: true]}], "hexpm", "bf9f351b01dd6bceea1d1f157f05438f6765ce606e6eb8d29296003d29bf6eab"},
|
||||
"bandit": {:hex, :bandit, "1.8.0", "c2e93d7e3c5c794272fa4623124f827c6f24b643acc822be64c826f9447d92fb", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "8458ff4eed20ff2a2ea69d4854883a077c33ea42b51f6811b044ceee0fa15422"},
|
||||
"bcrypt_elixir": {:hex, :bcrypt_elixir, "3.3.2", "d50091e3c9492d73e17fc1e1619a9b09d6a5ef99160eb4d736926fd475a16ca3", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "471be5151874ae7931911057d1467d908955f93554f7a6cd1b7d804cac8cef53"},
|
||||
"bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"},
|
||||
"castore": {:hex, :castore, "1.0.15", "8aa930c890fe18b6fe0a0cff27b27d0d4d231867897bd23ea772dee561f032a3", [:mix], [], "hexpm", "96ce4c69d7d5d7a0761420ef743e2f4096253931a3ba69e5ff8ef1844fe446d3"},
|
||||
"castore": {:hex, :castore, "1.0.16", "8a4f9a7c8b81cda88231a08fe69e3254f16833053b23fa63274b05cbc61d2a1e", [:mix], [], "hexpm", "33689203a0eaaf02fcd0e86eadfbcf1bd636100455350592e7e2628564022aaf"},
|
||||
"cc_precompiler": {:hex, :cc_precompiler, "0.1.11", "8c844d0b9fb98a3edea067f94f616b3f6b29b959b6b3bf25fee94ffe34364768", [:mix], [{:elixir_make, "~> 0.7", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "3427232caf0835f94680e5bcf082408a70b48ad68a5f5c0b02a3bea9f3a075b9"},
|
||||
"circular_buffer": {:hex, :circular_buffer, "1.0.0", "25c004da0cba7bd8bc1bdabded4f9a902d095e20600fd15faf1f2ffbaea18a07", [:mix], [], "hexpm", "c829ec31c13c7bafd1f546677263dff5bfb006e929f25635878ac3cfba8749e5"},
|
||||
"comeonin": {:hex, :comeonin, "5.5.1", "5113e5f3800799787de08a6e0db307133850e635d34e9fab23c70b6501669510", [:mix], [], "hexpm", "65aac8f19938145377cee73973f192c5645873dcf550a8a6b18187d17c13ccdb"},
|
||||
"credo": {:hex, :credo, "1.7.13", "126a0697df6b7b71cd18c81bc92335297839a806b6f62b61d417500d1070ff4e", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "47641e6d2bbff1e241e87695b29f617f1a8f912adea34296fb10ecc3d7e9e84f"},
|
||||
"crux": {:hex, :crux, "0.1.1", "94f2f97d2a6079ae3c57f356412bc3b307f9579a80e43f526447b1d508dd4a72", [:mix], [{:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}, {:simple_sat, ">= 0.1.1 and < 1.0.0-0", [hex: :simple_sat, repo: "hexpm", optional: true]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: true]}], "hexpm", "e59d498f038193cbe31e448f9199f5b4c53a4c67cece9922bb839595189dd2b6"},
|
||||
"credo": {:hex, :credo, "1.7.14", "c7e75216cea8d978ba8c60ed9dede4cc79a1c99a266c34b3600dd2c33b96bc92", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "12a97d6bb98c277e4fb1dff45aaf5c137287416009d214fb46e68147bd9e0203"},
|
||||
"crux": {:hex, :crux, "0.1.2", "4441c9e3a34f1e340954ce96b9ad5a2de13ceb4f97b3f910211227bb92e2ca90", [:mix], [{:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}, {:simple_sat, ">= 0.1.1 and < 1.0.0-0", [hex: :simple_sat, repo: "hexpm", optional: true]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: true]}], "hexpm", "563ea3748ebfba9cc078e6d198a1d6a06015a8fae503f0b721363139f0ddb350"},
|
||||
"db_connection": {:hex, :db_connection, "2.8.1", "9abdc1e68c34c6163f6fb96a96532272d13ad7ca45262156ae8b7ec6d9dc4bec", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a61a3d489b239d76f326e03b98794fb8e45168396c925ef25feb405ed09da8fd"},
|
||||
"decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"},
|
||||
"dns_cluster": {:hex, :dns_cluster, "0.2.0", "aa8eb46e3bd0326bd67b84790c561733b25c5ba2fe3c7e36f28e88f384ebcb33", [:mix], [], "hexpm", "ba6f1893411c69c01b9e8e8f772062535a4cf70f3f35bcc964a324078d8c8240"},
|
||||
"ecto": {:hex, :ecto, "3.13.3", "6a983f0917f8bdc7a89e96f2bf013f220503a0da5d8623224ba987515b3f0d80", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "1927db768f53a88843ff25b6ba7946599a8ca8a055f69ad8058a1432a399af94"},
|
||||
"ecto": {:hex, :ecto, "3.13.5", "9d4a69700183f33bf97208294768e561f5c7f1ecf417e0fa1006e4a91713a834", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "df9efebf70cf94142739ba357499661ef5dbb559ef902b68ea1f3c1fabce36de"},
|
||||
"ecto_commons": {:hex, :ecto_commons, "0.3.6", "7b1d9e59396cf8c8cbe5a26d50d03f9b6d0fe6c640210dd503622f276f1e59bb", [:mix], [{:burnex, "~> 3.0", [hex: :burnex, repo: "hexpm", optional: true]}, {:ecto, "~> 3.4", [hex: :ecto, repo: "hexpm", optional: false]}, {:ex_phone_number, "~> 0.2", [hex: :ex_phone_number, repo: "hexpm", optional: false]}, {:luhn, "~> 0.3.0", [hex: :luhn, repo: "hexpm", optional: false]}], "hexpm", "3f12981a1e398f206c5d2014e7b732b7ec91b110b9cb84875cb5b28fc75d7a0a"},
|
||||
"ecto_sql": {:hex, :ecto_sql, "3.13.2", "a07d2461d84107b3d037097c822ffdd36ed69d1cf7c0f70e12a3d1decf04e2e1", [:mix], [{:db_connection, "~> 2.4.1 or ~> 2.5", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.7", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.19 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "539274ab0ecf1a0078a6a72ef3465629e4d6018a3028095dc90f60a19c371717"},
|
||||
"elixir_make": {:hex, :elixir_make, "0.9.0", "6484b3cd8c0cee58f09f05ecaf1a140a8c97670671a6a0e7ab4dc326c3109726", [:mix], [], "hexpm", "db23d4fd8b757462ad02f8aa73431a426fe6671c80b200d9710caf3d1dd0ffdb"},
|
||||
"esbuild": {:hex, :esbuild, "0.10.0", "b0aa3388a1c23e727c5a3e7427c932d89ee791746b0081bbe56103e9ef3d291f", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "468489cda427b974a7cc9f03ace55368a83e1a7be12fba7e30969af78e5f8c70"},
|
||||
"ets": {:hex, :ets, "0.9.0", "79c6a6c205436780486f72d84230c6cba2f8a9920456750ddd1e47389107d5fd", [:mix], [], "hexpm", "2861fdfb04bcaeff370f1a5904eec864f0a56dcfebe5921ea9aadf2a481c822b"},
|
||||
"ex_phone_number": {:hex, :ex_phone_number, "0.4.8", "c1c5e6f0673822a2a7b439b43af7d3eb1a5c19ae582b772b8b8d12625dd51ec1", [:mix], [{:sweet_xml, "~> 0.7", [hex: :sweet_xml, repo: "hexpm", optional: false]}], "hexpm", "43e2357c6b8cfe556bcd417f4ce9aaef267a786e31a2938902daaa0d36f69757"},
|
||||
"expo": {:hex, :expo, "1.1.0", "f7b9ed7fb5745ebe1eeedf3d6f29226c5dd52897ac67c0f8af62a07e661e5c75", [:mix], [], "hexpm", "fbadf93f4700fb44c331362177bdca9eeb8097e8b0ef525c9cc501cb9917c960"},
|
||||
"expo": {:hex, :expo, "1.1.1", "4202e1d2ca6e2b3b63e02f69cfe0a404f77702b041d02b58597c00992b601db5", [], [], "hexpm", "5fb308b9cb359ae200b7e23d37c76978673aa1b06e2b3075d814ce12c5811640"},
|
||||
"file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"},
|
||||
"finch": {:hex, :finch, "0.20.0", "5330aefb6b010f424dcbbc4615d914e9e3deae40095e73ab0c1bb0968933cadf", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.6.2 or ~> 1.7", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 1.1", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "2658131a74d051aabfcba936093c903b8e89da9a1b63e430bee62045fa9b2ee2"},
|
||||
"fine": {:hex, :fine, "0.1.4", "b19a89c1476c7c57afb5f9314aed5960b5bc95d5277de4cb5ee8e1d1616ce379", [:mix], [], "hexpm", "be3324cc454a42d80951cf6023b9954e9ff27c6daa255483b3e8d608670303f5"},
|
||||
|
|
@ -35,14 +35,14 @@
|
|||
"heroicons": {:git, "https://github.com/tailwindlabs/heroicons.git", "0435d4ca364a608cc75e2f8683d374e55abbae26", [tag: "v2.2.0", sparse: "optimized", depth: 1]},
|
||||
"hpax": {:hex, :hpax, "1.0.3", "ed67ef51ad4df91e75cc6a1494f851850c0bd98ebc0be6e81b026e765ee535aa", [:mix], [], "hexpm", "8eab6e1cfa8d5918c2ce4ba43588e894af35dbd8e91e6e55c817bca5847df34a"},
|
||||
"idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"},
|
||||
"igniter": {:hex, :igniter, "0.6.30", "83a466369ebb8fe009e0823c7bf04314dc545122c2d48f896172fc79df33e99d", [:mix], [{:glob_ex, "~> 0.1.7", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:owl, "~> 0.11", [hex: :owl, repo: "hexpm", optional: false]}, {:phx_new, "~> 1.7", [hex: :phx_new, repo: "hexpm", optional: true]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}, {:rewrite, ">= 1.1.1 and < 2.0.0-0", [hex: :rewrite, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.4", [hex: :sourceror, repo: "hexpm", optional: false]}, {:spitfire, ">= 0.1.3 and < 1.0.0-0", [hex: :spitfire, repo: "hexpm", optional: false]}], "hexpm", "76a14d5b7f850bb03b5243088c3649d54a2e52e34a2aa1104dee23cf50a8bae0"},
|
||||
"igniter": {:hex, :igniter, "0.7.0", "6848714fa5afa14258c82924a57af9364745316241a409435cf39cbe11e3ae80", [:mix], [{:glob_ex, "~> 0.1.7", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:owl, "~> 0.11", [hex: :owl, repo: "hexpm", optional: false]}, {:phx_new, "~> 1.7", [hex: :phx_new, repo: "hexpm", optional: true]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}, {:rewrite, ">= 1.1.1 and < 2.0.0-0", [hex: :rewrite, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.4", [hex: :sourceror, repo: "hexpm", optional: false]}, {:spitfire, ">= 0.1.3 and < 1.0.0-0", [hex: :spitfire, repo: "hexpm", optional: false]}], "hexpm", "1e7254780dbf4b44c9eccd6d86d47aa961efc298d7f520c24acb0258c8e90ba9"},
|
||||
"iterex": {:hex, :iterex, "0.1.2", "58f9b9b9a22a55cbfc7b5234a9c9c63eaac26d276b3db80936c0e1c60355a5a6", [:mix], [], "hexpm", "2e103b8bcc81757a9af121f6dc0df312c9a17220f302b1193ef720460d03029d"},
|
||||
"jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"},
|
||||
"joken": {:hex, :joken, "2.6.2", "5daaf82259ca603af4f0b065475099ada1b2b849ff140ccd37f4b6828ca6892a", [:mix], [{:jose, "~> 1.11.10", [hex: :jose, repo: "hexpm", optional: false]}], "hexpm", "5134b5b0a6e37494e46dbf9e4dad53808e5e787904b7c73972651b51cce3d72b"},
|
||||
"jose": {:hex, :jose, "1.11.10", "a903f5227417bd2a08c8a00a0cbcc458118be84480955e8d251297a425723f83", [:mix, :rebar3], [], "hexpm", "0d6cd36ff8ba174db29148fc112b5842186b68a90ce9fc2b3ec3afe76593e614"},
|
||||
"jose": {:hex, :jose, "1.11.12", "06e62b467b61d3726cbc19e9b5489f7549c37993de846dfb3ee8259f9ed208b3", [], [], "hexpm", "31e92b653e9210b696765cdd885437457de1add2a9011d92f8cf63e4641bab7b"},
|
||||
"lazy_html": {:hex, :lazy_html, "0.1.8", "677a8642e644eef8de98f3040e2520d42d0f0f8bd6c5cd49db36504e34dffe91", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.9.0", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:fine, "~> 0.1.0", [hex: :fine, repo: "hexpm", optional: false]}], "hexpm", "0d8167d930b704feb94b41414ca7f5779dff9bca7fcf619fcef18de138f08736"},
|
||||
"libgraph": {:hex, :libgraph, "0.16.0", "3936f3eca6ef826e08880230f806bfea13193e49bf153f93edcf0239d4fd1d07", [:mix], [], "hexpm", "41ca92240e8a4138c30a7e06466acc709b0cbb795c643e9e17174a178982d6bf"},
|
||||
"live_debugger": {:hex, :live_debugger, "0.4.2", "775c3a570ef3c44d27d261b3c1aae23ef35cac949a57f67b3e7b1aa1fb2707bc", [:mix], [{:igniter, ">= 0.5.40 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.7", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.20.8 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}], "hexpm", "5b24e37985f0424056a322a18dab4a5fb0f4e8ee4e55975985364e0b45d683b9"},
|
||||
"live_debugger": {:hex, :live_debugger, "0.5.0", "95e0f7727d61010f7e9053923fb2a9416904a7533c2dfb36120e7684cba4c0af", [:mix], [{:igniter, ">= 0.5.40 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.7", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.20.8 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}], "hexpm", "73ebe95118d22aa402675f677abd731cb16b136d1b6ae5f4010441fb50753b14"},
|
||||
"luhn": {:hex, :luhn, "0.3.3", "5aa0c6a32c2db4b9db9f9b883ba8301c1ae169d57199b9e6cb1ba2707bc51d96", [:mix], [], "hexpm", "3e823a913a25aab51352c727f135278d22954874d5f0835be81ed4fec3daf78d"},
|
||||
"mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"},
|
||||
"mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"},
|
||||
|
|
@ -50,26 +50,26 @@
|
|||
"nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"},
|
||||
"nimble_pool": {:hex, :nimble_pool, "1.1.0", "bf9c29fbdcba3564a8b800d1eeb5a3c58f36e1e11d7b7fb2e084a643f645f06b", [:mix], [], "hexpm", "af2e4e6b34197db81f7aad230c1118eac993acc0dae6bc83bac0126d4ae0813a"},
|
||||
"owl": {:hex, :owl, "0.13.0", "26010e066d5992774268f3163506972ddac0a7e77bfe57fa42a250f24d6b876e", [:mix], [{:ucwidth, "~> 0.2", [hex: :ucwidth, repo: "hexpm", optional: true]}], "hexpm", "59bf9d11ce37a4db98f57cb68fbfd61593bf419ec4ed302852b6683d3d2f7475"},
|
||||
"phoenix": {:hex, :phoenix, "1.8.1", "865473a60a979551a4879db79fbfb4503e41cd809e77c85af79716578b6a456d", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "84d77d2b2e77c3c7e7527099bd01ef5c8560cd149c036d6b3a40745f11cd2fb2"},
|
||||
"phoenix_ecto": {:hex, :phoenix_ecto, "4.6.5", "c4ef322acd15a574a8b1a08eff0ee0a85e73096b53ce1403b6563709f15e1cea", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "26ec3208eef407f31b748cadd044045c6fd485fbff168e35963d2f9dfff28d4b"},
|
||||
"phoenix": {:hex, :phoenix, "1.8.2", "75aba5b90081d88a54f2fc6a26453d4e76762ab095ff89be5a3e7cb28bff9300", [:mix], [{:bandit, "~> 1.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.7", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "19ea65b4064f17b1ab0515595e4d0ea65742ab068259608d5d7b139a73f47611"},
|
||||
"phoenix_ecto": {:hex, :phoenix_ecto, "4.7.0", "75c4b9dfb3efdc42aec2bd5f8bccd978aca0651dbcbc7a3f362ea5d9d43153c6", [:mix], [{:ecto, "~> 3.5", [hex: :ecto, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:plug, "~> 1.9", [hex: :plug, repo: "hexpm", optional: false]}, {:postgrex, "~> 0.16 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}], "hexpm", "1d75011e4254cb4ddf823e81823a9629559a1be93b4321a6a5f11a5306fbf4cc"},
|
||||
"phoenix_html": {:hex, :phoenix_html, "4.3.0", "d3577a5df4b6954cd7890c84d955c470b5310bb49647f0a114a6eeecc850f7ad", [:mix], [], "hexpm", "3eaa290a78bab0f075f791a46a981bbe769d94bc776869f4f3063a14f30497ad"},
|
||||
"phoenix_html_helpers": {:hex, :phoenix_html_helpers, "1.0.1", "7eed85c52eff80a179391036931791ee5d2f713d76a81d0d2c6ebafe1e11e5ec", [:mix], [{:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "cffd2385d1fa4f78b04432df69ab8da63dc5cf63e07b713a4dcf36a3740e3090"},
|
||||
"phoenix_live_dashboard": {:hex, :phoenix_live_dashboard, "0.8.7", "405880012cb4b706f26dd1c6349125bfc903fb9e44d1ea668adaf4e04d4884b7", [:mix], [{:ecto, "~> 3.6.2 or ~> 3.7", [hex: :ecto, repo: "hexpm", optional: true]}, {:ecto_mysql_extras, "~> 0.5", [hex: :ecto_mysql_extras, repo: "hexpm", optional: true]}, {:ecto_psql_extras, "~> 0.7", [hex: :ecto_psql_extras, repo: "hexpm", optional: true]}, {:ecto_sqlite3_extras, "~> 1.1.7 or ~> 1.2.0", [hex: :ecto_sqlite3_extras, repo: "hexpm", optional: true]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.19 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:telemetry_metrics, "~> 0.6 or ~> 1.0", [hex: :telemetry_metrics, repo: "hexpm", optional: false]}], "hexpm", "3a8625cab39ec261d48a13b7468dc619c0ede099601b084e343968309bd4d7d7"},
|
||||
"phoenix_live_reload": {:hex, :phoenix_live_reload, "1.6.1", "05df733a09887a005ed0d69a7fc619d376aea2730bf64ce52ac51ce716cc1ef0", [:mix], [{:file_system, "~> 0.2.10 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.4", [hex: :phoenix, repo: "hexpm", optional: false]}], "hexpm", "74273843d5a6e4fef0bbc17599f33e3ec63f08e69215623a0cd91eea4288e5a0"},
|
||||
"phoenix_live_view": {:hex, :phoenix_live_view, "1.1.14", "cae84abc4cd00dde4bb200b8516db556704c585c267aff9cd4955ff83cceb86c", [:mix], [{:igniter, ">= 0.6.16 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:lazy_html, "~> 0.1.0", [hex: :lazy_html, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0-rc", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "b827980e2bc00fddd8674e3b567519a4e855b5de04bf8607140414f1101e2627"},
|
||||
"phoenix_pubsub": {:hex, :phoenix_pubsub, "2.1.3", "3168d78ba41835aecad272d5e8cd51aa87a7ac9eb836eabc42f6e57538e3731d", [:mix], [], "hexpm", "bba06bc1dcfd8cb086759f0edc94a8ba2bc8896d5331a1e2c2902bf8e36ee502"},
|
||||
"phoenix_live_view": {:hex, :phoenix_live_view, "1.1.18", "b5410017b3d4edf261d9c98ebc334e0637d7189457c730720cfc13e206443d43", [:mix], [{:igniter, ">= 0.6.16 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:lazy_html, "~> 0.1.0", [hex: :lazy_html, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.6.15 or ~> 1.7.0 or ~> 1.8.0-rc", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 3.3 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.15", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.2 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "f189b759595feff0420e9a1d544396397f9cf9e2d5a8cb98ba5b6cab01927da0"},
|
||||
"phoenix_pubsub": {:hex, :phoenix_pubsub, "2.2.0", "ff3a5616e1bed6804de7773b92cbccfc0b0f473faf1f63d7daf1206c7aeaaa6f", [:mix], [], "hexpm", "adc313a5bf7136039f63cfd9668fde73bba0765e0614cba80c06ac9460ff3e96"},
|
||||
"phoenix_template": {:hex, :phoenix_template, "1.0.4", "e2092c132f3b5e5b2d49c96695342eb36d0ed514c5b252a77048d5969330d639", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "2c0c81f0e5c6753faf5cca2f229c9709919aba34fab866d3bc05060c9c444206"},
|
||||
"phoenix_view": {:hex, :phoenix_view, "2.0.4", "b45c9d9cf15b3a1af5fb555c674b525391b6a1fe975f040fb4d913397b31abf4", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}], "hexpm", "4e992022ce14f31fe57335db27a28154afcc94e9983266835bb3040243eb620b"},
|
||||
"plug": {:hex, :plug, "1.18.1", "5067f26f7745b7e31bc3368bc1a2b818b9779faa959b49c934c17730efc911cf", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "57a57db70df2b422b564437d2d33cf8d33cd16339c1edb190cd11b1a3a546cc2"},
|
||||
"plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"},
|
||||
"postgrex": {:hex, :postgrex, "0.21.1", "2c5cc830ec11e7a0067dd4d623c049b3ef807e9507a424985b8dcf921224cd88", [:mix], [{:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "27d8d21c103c3cc68851b533ff99eef353e6a0ff98dc444ea751de43eb48bdac"},
|
||||
"reactor": {:hex, :reactor, "0.17.0", "eb8bdb530dbae824e2d36a8538f8ec4f3aa7c2d1b61b04959fa787c634f88b49", [:mix], [{:igniter, "~> 0.4", [hex: :igniter, repo: "hexpm", optional: true]}, {:iterex, "~> 0.1", [hex: :iterex, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:libgraph, "~> 0.16", [hex: :libgraph, repo: "hexpm", optional: false]}, {:spark, ">= 2.3.3 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.2", [hex: :splode, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.2", [hex: :telemetry, repo: "hexpm", optional: false]}, {:yaml_elixir, "~> 2.11", [hex: :yaml_elixir, repo: "hexpm", optional: false]}, {:ymlr, "~> 5.0", [hex: :ymlr, repo: "hexpm", optional: false]}], "hexpm", "3c3bf71693adbad9117b11ec83cfed7d5851b916ade508ed9718de7ae165bf25"},
|
||||
"req": {:hex, :req, "0.5.15", "662020efb6ea60b9f0e0fac9be88cd7558b53fe51155a2d9899de594f9906ba9", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "a6513a35fad65467893ced9785457e91693352c70b58bbc045b47e5eb2ef0c53"},
|
||||
"req": {:hex, :req, "0.5.16", "99ba6a36b014458e52a8b9a0543bfa752cb0344b2a9d756651db1281d4ba4450", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "974a7a27982b9b791df84e8f6687d21483795882a7840e8309abdbe08bb06f09"},
|
||||
"rewrite": {:hex, :rewrite, "1.2.0", "80220eb14010e175b67c939397e1a8cdaa2c32db6e2e0a9d5e23e45c0414ce21", [:mix], [{:glob_ex, "~> 0.1", [hex: :glob_ex, repo: "hexpm", optional: false]}, {:sourceror, "~> 1.0", [hex: :sourceror, repo: "hexpm", optional: false]}, {:text_diff, "~> 0.1", [hex: :text_diff, repo: "hexpm", optional: false]}], "hexpm", "a1cd702bbb9d51613ab21091f04a386d750fc6f4516b81900df082d78b2d8c50"},
|
||||
"slugify": {:hex, :slugify, "1.3.1", "0d3b8b7e5c1eeaa960e44dce94382bee34a39b3ea239293e457a9c5b47cc6fd3", [:mix], [], "hexpm", "cb090bbeb056b312da3125e681d98933a360a70d327820e4b7f91645c4d8be76"},
|
||||
"sobelow": {:hex, :sobelow, "0.14.1", "2f81e8632f15574cba2402bcddff5497b413c01e6f094bc0ab94e83c2f74db81", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "8fac9a2bd90fdc4b15d6fca6e1608efb7f7c600fa75800813b794ee9364c87f2"},
|
||||
"sourceror": {:hex, :sourceror, "1.10.0", "38397dedbbc286966ec48c7af13e228b171332be1ad731974438c77791945ce9", [:mix], [], "hexpm", "29dbdfc92e04569c9d8e6efdc422fc1d815f4bd0055dc7c51b8800fb75c4b3f1"},
|
||||
"spark": {:hex, :spark, "2.3.5", "f30d30ecc3b4ab9b932d9aada66af7677fc1f297a2c349b0bcec3eafb9f996e8", [:mix], [{:igniter, ">= 0.3.64 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: true]}, {:sourceror, "~> 1.2", [hex: :sourceror, repo: "hexpm", optional: true]}], "hexpm", "0e9d339704d5d148f77f2b2fef3bcfc873a9e9bb4224fcf289c545d65827202f"},
|
||||
"spark": {:hex, :spark, "2.3.14", "a08420d08e6e0e49d740aed3e160f1cb894ba8f6b3f5e6c63253e9df1995265c", [:mix], [{:igniter, ">= 0.3.64 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: true]}, {:sourceror, "~> 1.2", [hex: :sourceror, repo: "hexpm", optional: true]}], "hexpm", "af50c4ea5dd67eba822247f1c98e1d4e598cb7f6c28ccf5d002f0e0718096f4f"},
|
||||
"spitfire": {:hex, :spitfire, "0.2.1", "29e154873f05444669c7453d3d931820822cbca5170e88f0f8faa1de74a79b47", [:mix], [], "hexpm", "6eeed75054a38341b2e1814d41bb0a250564092358de2669fdb57ff88141d91b"},
|
||||
"splode": {:hex, :splode, "0.2.9", "3a2776e187c82f42f5226b33b1220ccbff74f4bcc523dd4039c804caaa3ffdc7", [:mix], [], "hexpm", "8002b00c6e24f8bd1bcced3fbaa5c33346048047bb7e13d2f3ad428babbd95c3"},
|
||||
"stream_data": {:hex, :stream_data, "1.2.0", "58dd3f9e88afe27dc38bef26fce0c84a9e7a96772b2925c7b32cd2435697a52b", [:mix], [], "hexpm", "eb5c546ee3466920314643edf68943a5b14b32d1da9fe01698dc92b73f89a9ed"},
|
||||
|
|
@ -80,11 +80,11 @@
|
|||
"telemetry_metrics": {:hex, :telemetry_metrics, "1.1.0", "5bd5f3b5637e0abea0426b947e3ce5dd304f8b3bc6617039e2b5a008adc02f8f", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "e7b79e8ddfde70adb6db8a6623d1778ec66401f366e9a8f5dd0955c56bc8ce67"},
|
||||
"telemetry_poller": {:hex, :telemetry_poller, "1.3.0", "d5c46420126b5ac2d72bc6580fb4f537d35e851cc0f8dbd571acf6d6e10f5ec7", [:rebar3], [{:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "51f18bed7128544a50f75897db9974436ea9bfba560420b646af27a9a9b35211"},
|
||||
"text_diff": {:hex, :text_diff, "0.1.0", "1caf3175e11a53a9a139bc9339bd607c47b9e376b073d4571c031913317fecaa", [:mix], [], "hexpm", "d1ffaaecab338e49357b6daa82e435f877e0649041ace7755583a0ea3362dbd7"},
|
||||
"thousand_island": {:hex, :thousand_island, "1.4.1", "8df065e627407e281f7935da5ad0f3842d10eb721afa92e760b720d71e2e37aa", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "204a8640e5d2818589b87286ae66160978628d7edf6095181cbe0440765fb6c1"},
|
||||
"tidewave": {:hex, :tidewave, "0.5.0", "8f278d7eb2d0af36ae6d4f73a5872bd066815bd57b57401125187ba901f095a4", [:mix], [{:circular_buffer, "~> 0.4 or ~> 1.0", [hex: :circular_buffer, repo: "hexpm", optional: false]}, {:igniter, "~> 0.6", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix_live_reload, ">= 1.6.1", [hex: :phoenix_live_reload, repo: "hexpm", optional: true]}, {:plug, "~> 1.17", [hex: :plug, repo: "hexpm", optional: false]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}], "hexpm", "9a1eb5d2f12ff4912328dfbfe652c27fded462c6ed6fd11814ee28d3e9d016b4"},
|
||||
"thousand_island": {:hex, :thousand_island, "1.4.2", "735fa783005d1703359bbd2d3a5a3a398075ba4456e5afe3c5b7cf4666303d36", [], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "1c7637f16558fc1c35746d5ee0e83b18b8e59e18d28affd1f2fa1645f8bc7473"},
|
||||
"tidewave": {:hex, :tidewave, "0.5.2", "f549acffe9daeed8b6b547c232c60de987770da7f827f9b3300140dfc465b102", [:mix], [{:circular_buffer, "~> 0.4 or ~> 1.0", [hex: :circular_buffer, repo: "hexpm", optional: false]}, {:igniter, "~> 0.6", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix_live_reload, ">= 1.6.1", [hex: :phoenix_live_reload, repo: "hexpm", optional: true]}, {:plug, "~> 1.17", [hex: :plug, repo: "hexpm", optional: false]}, {:req, "~> 0.5", [hex: :req, repo: "hexpm", optional: false]}], "hexpm", "34ab3ffee7e402f05cd1eae68d0e77ed0e0d1925677971ef83634247553e8afd"},
|
||||
"unicode_util_compat": {:hex, :unicode_util_compat, "0.7.1", "a48703a25c170eedadca83b11e88985af08d35f37c6f664d6dcfb106a97782fc", [:rebar3], [], "hexpm", "b3a917854ce3ae233619744ad1e0102e05673136776fb2fa76234f3e03b23642"},
|
||||
"websock": {:hex, :websock, "0.5.3", "2f69a6ebe810328555b6fe5c831a851f485e303a7c8ce6c5f675abeb20ebdadc", [:mix], [], "hexpm", "6105453d7fac22c712ad66fab1d45abdf049868f253cf719b625151460b8b453"},
|
||||
"websock_adapter": {:hex, :websock_adapter, "0.5.8", "3b97dc94e407e2d1fc666b2fb9acf6be81a1798a2602294aac000260a7c4a47d", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "315b9a1865552212b5f35140ad194e67ce31af45bcee443d4ecb96b5fd3f3782"},
|
||||
"websock_adapter": {:hex, :websock_adapter, "0.5.9", "43dc3ba6d89ef5dec5b1d0a39698436a1e856d000d84bf31a3149862b01a287f", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "5534d5c9adad3c18a0f58a9371220d75a803bf0b9a3d87e6fe072faaeed76a08"},
|
||||
"yamerl": {:hex, :yamerl, "0.10.0", "4ff81fee2f1f6a46f1700c0d880b24d193ddb74bd14ef42cb0bcf46e81ef2f8e", [:rebar3], [], "hexpm", "346adb2963f1051dc837a2364e4acf6eb7d80097c0f53cbdc3046ec8ec4b4e6e"},
|
||||
"yaml_elixir": {:hex, :yaml_elixir, "2.12.0", "30343ff5018637a64b1b7de1ed2a3ca03bc641410c1f311a4dbdc1ffbbf449c7", [:mix], [{:yamerl, "~> 0.10", [hex: :yamerl, repo: "hexpm", optional: false]}], "hexpm", "ca6bacae7bac917a7155dca0ab6149088aa7bc800c94d0fe18c5238f53b313c6"},
|
||||
"ymlr": {:hex, :ymlr, "5.1.4", "b924d61e1fc1ec371cde6ab3ccd9311110b1e052fc5c2460fb322e8380e7712a", [:mix], [], "hexpm", "75f16cf0709fbd911b30311a0359a7aa4b5476346c01882addefd5f2b1cfaa51"},
|
||||
|
|
|
|||
|
|
@ -783,6 +783,7 @@ msgstr "Tipp: E-Mail-Adressen ins BCC-Feld einfügen für Datenschutzkonformitä
|
|||
msgid "This field cannot be empty"
|
||||
msgstr "Dieses Feld darf nicht leer bleiben"
|
||||
|
||||
#: lib/mv_web/components/core_components.ex
|
||||
#: lib/mv_web/live/components/payment_filter_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "All"
|
||||
|
|
@ -1319,6 +1320,41 @@ msgstr ""
|
|||
msgid "Yearly Interval - Joining Period Included"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/components/field_visibility_dropdown_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Columns"
|
||||
msgstr "Spalten"
|
||||
|
||||
#: lib/mv_web/live/components/field_visibility_dropdown_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Custom Field %{id}"
|
||||
msgstr "Benutzerdefiniertes Feld %{id}"
|
||||
|
||||
#: lib/mv_web/live/member_live/index.html.heex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Last name"
|
||||
msgstr "Nachname"
|
||||
|
||||
#: lib/mv_web/components/core_components.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "None"
|
||||
msgstr "Keine"
|
||||
|
||||
#: lib/mv_web/components/core_components.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Options"
|
||||
msgstr "Optionen"
|
||||
|
||||
#: lib/mv_web/components/core_components.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Select all"
|
||||
msgstr "Alle auswählen"
|
||||
|
||||
#: lib/mv_web/components/core_components.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Select none"
|
||||
msgstr "Keine auswählen"
|
||||
|
||||
#: lib/mv_web/live/custom_field_live/form_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Back to custom field overview"
|
||||
|
|
|
|||
|
|
@ -784,6 +784,7 @@ msgstr ""
|
|||
msgid "This field cannot be empty"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/core_components.ex
|
||||
#: lib/mv_web/live/components/payment_filter_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "All"
|
||||
|
|
@ -1320,6 +1321,41 @@ msgstr ""
|
|||
msgid "Yearly Interval - Joining Period Included"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/components/field_visibility_dropdown_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Columns"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/components/field_visibility_dropdown_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Custom Field %{id}"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/index.html.heex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Last name"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/core_components.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "None"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/core_components.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Options"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/core_components.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Select all"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/core_components.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Select none"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/custom_field_live/form_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Back to custom field overview"
|
||||
|
|
|
|||
|
|
@ -784,6 +784,7 @@ msgstr ""
|
|||
msgid "This field cannot be empty"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/core_components.ex
|
||||
#: lib/mv_web/live/components/payment_filter_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "All"
|
||||
|
|
@ -1320,6 +1321,41 @@ msgstr ""
|
|||
msgid "Yearly Interval - Joining Period Included"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/components/field_visibility_dropdown_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Columns"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/components/field_visibility_dropdown_component.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Custom Field %{id}"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/member_live/index.html.heex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Last name"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/core_components.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "None"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/core_components.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Options"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/core_components.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Select all"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/components/core_components.ex
|
||||
#, elixir-autogen, elixir-format, fuzzy
|
||||
msgid "Select none"
|
||||
msgstr ""
|
||||
|
||||
#: lib/mv_web/live/custom_field_live/form_component.ex
|
||||
#, elixir-autogen, elixir-format
|
||||
msgid "Back to custom field overview"
|
||||
|
|
|
|||
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 (use string keys for JSONB)
|
||||
{:ok, _updated_settings} =
|
||||
Mv.Membership.update_settings(settings, %{
|
||||
member_field_visibility: %{Atom.to_string(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 (use string keys for JSONB)
|
||||
visibility_config =
|
||||
Enum.reduce(fields_to_hide, %{}, fn field, acc ->
|
||||
Map.put(acc, Atom.to_string(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,21 @@
|
|||
defmodule MvWeb.Components.FieldVisibilityDropdownComponentTest do
|
||||
use MvWeb.ConnCase, async: true
|
||||
import Phoenix.LiveViewTest
|
||||
|
||||
describe "field visibility dropdown in member view" do
|
||||
test "renders and toggles visibility", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
{:ok, view, _html} = live(conn, ~p"/members")
|
||||
|
||||
# Renders Dropdown
|
||||
assert has_element?(view, "[data-testid='dropdown-menu']")
|
||||
|
||||
# Opens Dropdown
|
||||
view |> element("[data-testid='dropdown-button']") |> render_click()
|
||||
assert has_element?(view, "#field-visibility-menu")
|
||||
assert has_element?(view, "button[phx-click='select_item'][phx-value-item='email']")
|
||||
assert has_element?(view, "button[phx-click='select_all']")
|
||||
assert has_element?(view, "button[phx-click='select_none']")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
@ -150,35 +150,27 @@ defmodule MvWeb.Components.SortHeaderComponentTest do
|
|||
assert has_element?(view, "[data-testid='email'] .opacity-40")
|
||||
end
|
||||
|
||||
test "icon distribution is correct for all fields", %{conn: conn} do
|
||||
test "icon distribution shows exactly one active sort icon", %{conn: conn} do
|
||||
conn = conn_with_oidc_user(conn)
|
||||
|
||||
# Test neutral state - all fields except first name (default) should show neutral icons
|
||||
# Test neutral state - only one field should have active sort icon
|
||||
{:ok, _view, html_neutral} = live(conn, "/members")
|
||||
|
||||
# Count neutral icons (should be 7 - one for each field)
|
||||
neutral_count =
|
||||
html_neutral |> String.split("hero-chevron-up-down") |> length() |> Kernel.-(1)
|
||||
|
||||
assert neutral_count == 7
|
||||
|
||||
# Count active icons (should be 1)
|
||||
# Count active icons (should be exactly 1 - ascending for default sort field)
|
||||
up_count = html_neutral |> String.split("hero-chevron-up ") |> length() |> Kernel.-(1)
|
||||
down_count = html_neutral |> String.split("hero-chevron-down ") |> length() |> Kernel.-(1)
|
||||
assert up_count == 1
|
||||
assert down_count == 0
|
||||
|
||||
# Test ascending state - one field active, others neutral
|
||||
{:ok, _view, html_asc} = live(conn, "/members?sort_field=first_name&sort_order=asc")
|
||||
assert up_count == 1, "Expected exactly 1 ascending icon, got #{up_count}"
|
||||
assert down_count == 0, "Expected 0 descending icons, got #{down_count}"
|
||||
|
||||
# Should have exactly 1 ascending icon and 7 neutral icons
|
||||
up_count = html_asc |> String.split("hero-chevron-up ") |> length() |> Kernel.-(1)
|
||||
neutral_count = html_asc |> String.split("hero-chevron-up-down") |> length() |> Kernel.-(1)
|
||||
down_count = html_asc |> String.split("hero-chevron-down ") |> length() |> Kernel.-(1)
|
||||
# Test descending state
|
||||
{:ok, _view, html_desc} = live(conn, "/members?sort_field=first_name&sort_order=desc")
|
||||
|
||||
assert up_count == 1
|
||||
assert neutral_count == 7
|
||||
assert down_count == 0
|
||||
up_count = html_desc |> String.split("hero-chevron-up ") |> length() |> Kernel.-(1)
|
||||
down_count = html_desc |> String.split("hero-chevron-down ") |> length() |> Kernel.-(1)
|
||||
|
||||
assert up_count == 0, "Expected 0 ascending icons, got #{up_count}"
|
||||
assert down_count == 1, "Expected exactly 1 descending icon, got #{down_count}"
|
||||
end
|
||||
end
|
||||
|
||||
|
|
|
|||
370
test/mv_web/live/member_live/index/field_selection_test.exs
Normal file
370
test/mv_web/live/member_live/index/field_selection_test.exs
Normal file
|
|
@ -0,0 +1,370 @@
|
|||
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 header is missing" do
|
||||
conn = %Plug.Conn{}
|
||||
|
||||
result = FieldSelection.get_from_cookie(conn)
|
||||
|
||||
assert result == %{}
|
||||
end
|
||||
|
||||
test "returns empty map when cookie is empty string" 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
|
||||
selection = %{"first_name" => true, "email" => false}
|
||||
cookie_value = selection |> Jason.encode!() |> URI.encode()
|
||||
cookie_header = "member_field_selection=#{cookie_value}"
|
||||
conn = %Plug.Conn{} |> Plug.Conn.put_req_header("cookie", cookie_header)
|
||||
|
||||
result = FieldSelection.get_from_cookie(conn)
|
||||
|
||||
assert result == selection
|
||||
end
|
||||
|
||||
test "handles invalid JSON in cookie gracefully" do
|
||||
cookie_value = URI.encode("invalid{[")
|
||||
cookie_header = "member_field_selection=#{cookie_value}"
|
||||
conn = %Plug.Conn{} |> Plug.Conn.put_req_header("cookie", cookie_header)
|
||||
|
||||
result = FieldSelection.get_from_cookie(conn)
|
||||
|
||||
assert result == %{}
|
||||
end
|
||||
|
||||
test "handles cookie with other values" do
|
||||
selection = %{"street" => true}
|
||||
cookie_value = selection |> Jason.encode!() |> URI.encode()
|
||||
cookie_header = "other_cookie=value; member_field_selection=#{cookie_value}; another=test"
|
||||
conn = %Plug.Conn{} |> Plug.Conn.put_req_header("cookie", cookie_header)
|
||||
|
||||
result = FieldSelection.get_from_cookie(conn)
|
||||
|
||||
assert result == selection
|
||||
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 (order may vary)
|
||||
fields = String.split(result, ",") |> Enum.sort()
|
||||
assert fields == ["email", "first_name"]
|
||||
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
|
||||
|
|
@ -242,7 +242,7 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsDisplayTest do
|
|||
assert html =~ "alice.private@example.com"
|
||||
end
|
||||
|
||||
test "shows empty cell or placeholder for members without custom field values", %{
|
||||
test "shows empty cell for members without custom field values", %{
|
||||
conn: conn,
|
||||
member2: _member2,
|
||||
field_show_string: field
|
||||
|
|
@ -253,11 +253,14 @@ defmodule MvWeb.MemberLive.IndexCustomFieldsDisplayTest do
|
|||
# The custom field column should exist
|
||||
assert html =~ field.name
|
||||
|
||||
# Member2 should have an empty cell for this field
|
||||
# We check that member2's row exists but doesn't have the value
|
||||
assert html =~ "Bob Brown"
|
||||
# The value should not appear for member2 (only for member1)
|
||||
# We check that the value appears somewhere (for member1) but member2 row should have "-"
|
||||
# Member2 should exist in the table (first_name and last_name are in separate columns)
|
||||
assert html =~ "Bob"
|
||||
assert html =~ "Brown"
|
||||
|
||||
# The value from member1 should appear (phone number)
|
||||
assert html =~ "+49123456789"
|
||||
|
||||
# Note: Member2 doesn't have this custom field value, so the cell is empty
|
||||
# The implementation shows "" for missing values, which is the expected behavior
|
||||
end
|
||||
end
|
||||
|
|
|
|||
452
test/mv_web/member_live/index_field_visibility_test.exs
Normal file
452
test/mv_web/member_live/index_field_visibility_test.exs
Normal file
|
|
@ -0,0 +1,452 @@
|
|||
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 "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")
|
||||
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(%{key: "Enter"})
|
||||
|
||||
# Wait for update
|
||||
:timer.sleep(100)
|
||||
|
||||
# Email should no longer be visible
|
||||
html = render(view)
|
||||
refute html =~ "alice@example.com"
|
||||
end
|
||||
end
|
||||
end
|
||||
Loading…
Add table
Add a link
Reference in a new issue