feat: adds field visibility dropdown live component
Some checks failed
continuous-integration/drone/push Build is failing

This commit is contained in:
carla 2025-12-02 15:00:09 +01:00
parent 45a9bc0cc0
commit 0fb43a0816
6 changed files with 981 additions and 33 deletions

View file

@ -31,6 +31,8 @@ defmodule MvWeb.MemberLive.Index do
alias Mv.Membership
alias MvWeb.MemberLive.Index.Formatter
alias MvWeb.MemberLive.Index.FieldSelection
alias MvWeb.MemberLive.Index.FieldVisibility
# Prefix used in sort field names for custom fields (e.g., "custom_field_<id>")
@custom_field_prefix "custom_field_"
@ -48,8 +50,8 @@ defmodule MvWeb.MemberLive.Index do
and member selection. Actual data loading happens in `handle_params/3`.
"""
@impl true
def mount(_params, _session, socket) do
# Load custom fields that should be shown in overview
def mount(_params, session, socket) do
# Load custom fields that should be shown in overview (for display)
# Note: Using Ash.read! (bang version) - errors will be handled by Phoenix LiveView
# and result in a 500 error page. This is appropriate for LiveViews where errors
# should be visible to the user rather than silently failing.
@ -59,6 +61,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
@ -67,6 +75,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"))
@ -76,8 +98,14 @@ defmodule MvWeb.MemberLive.Index do
|> assign(:selected_members, [])
|> assign(:settings, settings)
|> assign(:custom_fields_visible, custom_fields_visible)
|> assign(:all_custom_fields, all_custom_fields)
|> assign(:all_available_fields, all_available_fields)
|> assign(:user_field_selection, initial_selection)
|> assign(:member_field_configurations, get_member_field_configurations(settings))
|> assign(:member_fields_visible, get_visible_member_fields(settings))
|> assign(
:member_fields_visible,
FieldVisibility.get_visible_member_fields(initial_selection)
)
# We call handle params to use the query from the URL
{:ok, socket}
@ -144,6 +172,8 @@ defmodule MvWeb.MemberLive.Index do
## Supported messages:
- `{:sort, field}` - Sort event from SortHeaderComponent. Updates sort field/order and syncs URL
- `{:search_changed, query}` - Search event from SearchBarComponent. Filters members and syncs URL
- `{:field_toggled, field, visible}` - Field toggle event from FieldVisibilityDropdownComponent
- `{:fields_selected, selection}` - Select all/deselect all event from FieldVisibilityDropdownComponent
"""
@impl true
def handle_info({:sort, field_str}, socket) do
@ -170,11 +200,12 @@ defmodule MvWeb.MemberLive.Index do
existing_sort_query = socket.assigns.sort_order
# Build the URL with queries
query_params = %{
"query" => q,
"sort_field" => existing_field_query,
"sort_order" => existing_sort_query
}
query_params =
build_query_params(socket, %{
"query" => q,
"sort_field" => existing_field_query,
"sort_order" => existing_sort_query
})
# Set the new path with params
new_path = ~p"/members?#{query_params}"
@ -187,22 +218,109 @@ defmodule MvWeb.MemberLive.Index do
)}
end
@impl true
def handle_info({:field_toggled, field_string, visible}, socket) do
# Update user field selection
new_selection = Map.put(socket.assigns.user_field_selection, field_string, visible)
# Save to session (cookie will be saved on next page load via handle_params)
socket = update_session_field_selection(socket, new_selection)
# Merge with global settings
final_selection =
FieldVisibility.merge_with_global_settings(
new_selection,
socket.assigns.settings,
socket.assigns.custom_fields_visible
)
# Get visible fields
visible_member_fields = FieldVisibility.get_visible_member_fields(final_selection)
visible_custom_fields = FieldVisibility.get_visible_custom_fields(final_selection)
socket =
socket
|> assign(:user_field_selection, final_selection)
|> assign(:member_fields_visible, visible_member_fields)
|> assign(:visible_custom_field_ids, extract_custom_field_ids(visible_custom_fields))
|> load_members(socket.assigns.query)
|> prepare_dynamic_cols()
|> push_field_selection_url()
{:noreply, socket}
end
@impl true
def handle_info({:fields_selected, selection}, socket) do
# Save to session
socket = update_session_field_selection(socket, selection)
# Merge with global settings (use all_custom_fields for merging)
final_selection =
FieldVisibility.merge_with_global_settings(
selection,
socket.assigns.settings,
socket.assigns.all_custom_fields
)
# Get visible fields
visible_member_fields = FieldVisibility.get_visible_member_fields(final_selection)
visible_custom_fields = FieldVisibility.get_visible_custom_fields(final_selection)
socket =
socket
|> assign(:user_field_selection, final_selection)
|> assign(:member_fields_visible, visible_member_fields)
|> assign(:visible_custom_field_ids, extract_custom_field_ids(visible_custom_fields))
|> load_members(socket.assigns.query)
|> prepare_dynamic_cols()
|> push_field_selection_url()
{:noreply, socket}
end
# -----------------------------------------------------------------
# Handle Params from the URL
# -----------------------------------------------------------------
@doc """
Handles URL parameter changes.
Parses query parameters for search query, sort field, and sort order,
Parses query parameters for search query, sort field, sort order, and field selection,
then loads members accordingly. This enables bookmarkable URLs and
browser back/forward navigation.
"""
@impl true
def handle_params(params, _url, socket) do
# Parse field selection from URL
url_selection = FieldSelection.parse_from_url(params)
# Merge with session selection (URL has priority)
merged_selection =
FieldSelection.merge_sources(
url_selection,
socket.assigns.user_field_selection,
%{}
)
# Merge with global settings (use all_custom_fields for merging)
final_selection =
FieldVisibility.merge_with_global_settings(
merged_selection,
socket.assigns.settings,
socket.assigns.all_custom_fields
)
# Get visible fields
visible_member_fields = FieldVisibility.get_visible_member_fields(final_selection)
visible_custom_fields = FieldVisibility.get_visible_custom_fields(final_selection)
socket =
socket
|> maybe_update_search(params)
|> maybe_update_sort(params)
|> assign(:user_field_selection, final_selection)
|> assign(:member_fields_visible, visible_member_fields)
|> assign(:visible_custom_field_ids, extract_custom_field_ids(visible_custom_fields))
|> load_members(params["query"])
|> prepare_dynamic_cols()
@ -215,10 +333,16 @@ defmodule MvWeb.MemberLive.Index do
# - `:custom_field` - The CustomField resource
# - `:render` - A function that formats the custom field value for a given member
#
# Only includes custom fields that are visible according to user field selection.
#
# Returns the socket with `:dynamic_cols` assigned.
defp prepare_dynamic_cols(socket) do
visible_custom_field_ids = socket.assigns[:visible_custom_field_ids] || []
dynamic_cols =
Enum.map(socket.assigns.custom_fields_visible, fn custom_field ->
socket.assigns.custom_fields_visible
|> Enum.filter(fn custom_field -> custom_field.id in visible_custom_field_ids end)
|> Enum.map(fn custom_field ->
%{
custom_field: custom_field,
render: fn member ->
@ -294,11 +418,11 @@ defmodule MvWeb.MemberLive.Index do
field
end
query_params = %{
"query" => socket.assigns.query,
"sort_field" => field_str,
"sort_order" => Atom.to_string(order)
}
query_params =
build_query_params(socket, %{
"sort_field" => field_str,
"sort_order" => Atom.to_string(order)
})
new_path = ~p"/members?#{query_params}"
@ -309,6 +433,47 @@ defmodule MvWeb.MemberLive.Index do
)}
end
# Builds query parameters including field selection
defp build_query_params(socket, base_params) do
base_params
|> Map.put("query", socket.assigns.query || "")
|> maybe_add_field_selection(socket.assigns[:user_field_selection])
end
# Adds field selection to query params if present
defp maybe_add_field_selection(params, nil), do: params
defp maybe_add_field_selection(params, selection) when is_map(selection) do
fields_param = FieldSelection.to_url_param(selection)
if fields_param != "", do: Map.put(params, "fields", fields_param), else: params
end
defp maybe_add_field_selection(params, _), do: params
# Pushes URL with updated field selection
defp push_field_selection_url(socket) do
query_params =
build_query_params(socket, %{
"sort_field" => field_to_string(socket.assigns.sort_field),
"sort_order" => Atom.to_string(socket.assigns.sort_order)
})
new_path = ~p"/members?#{query_params}"
push_patch(socket, to: new_path, replace: true)
end
# Converts field to string
defp field_to_string(field) when is_atom(field), do: Atom.to_string(field)
defp field_to_string(field) when is_binary(field), do: field
# Updates session field selection (stored in socket for now, actual session update via controller)
defp update_session_field_selection(socket, selection) do
# Store in socket for now - actual session persistence would require a controller
# This is a placeholder for future session persistence
assign(socket, :user_field_selection, selection)
end
# Loads members from the database with custom field values and applies search/sort filters.
#
# Process:
@ -333,9 +498,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)
@ -770,20 +935,6 @@ defmodule MvWeb.MemberLive.Index do
end)
end
# Gets the list of member fields that should be visible in the overview.
#
# Filters the member field configurations to return only fields with show_in_overview: true.
#
# Parameters:
# - `settings` - The settings struct loaded from the database
#
# Returns a list of atoms representing visible member field names.
@spec get_visible_member_fields(map()) :: [atom()]
defp get_visible_member_fields(settings) do
get_member_field_configurations(settings)
|> Enum.filter(fn {_field, show_in_overview} -> show_in_overview end)
|> Enum.map(fn {field, _show_in_overview} -> field end)
end
# Normalizes visibility config map keys from strings to atoms.
# JSONB in PostgreSQL converts atom keys to string keys when storing.
@ -808,4 +959,16 @@ defmodule MvWeb.MemberLive.Index do
end
defp normalize_visibility_config(_), do: %{}
# Extracts custom field IDs from visible custom field strings
# Format: "custom_field_<id>" -> <id>
defp extract_custom_field_ids(visible_custom_fields) do
Enum.map(visible_custom_fields, fn field_string ->
case String.split(field_string, "custom_field_") do
["", id] -> id
_ -> nil
end
end)
|> Enum.filter(&(&1 != nil))
end
end

View file

@ -2,6 +2,13 @@
<.header>
{gettext("Members")}
<:actions>
<.live_component
module={MvWeb.Components.FieldVisibilityDropdownComponent}
id="field-visibility-dropdown"
all_fields={@all_available_fields}
custom_fields={@all_custom_fields}
selected_fields={@user_field_selection}
/>
<.button variant="primary" navigate={~p"/members/new"}>
<.icon name="hero-plus" /> {gettext("New Member")}
</.button>
@ -54,6 +61,7 @@
</:col>
<:col
:let={member}
:if={:first_name in @member_fields_visible}
label={
~H"""
<.live_component
@ -67,7 +75,25 @@
"""
}
>
{member.first_name} {member.last_name}
{member.first_name}
</:col>
<:col
:let={member}
:if={:last_name in @member_fields_visible}
label={
~H"""
<.live_component
module={MvWeb.Components.SortHeaderComponent}
id={:sort_last_name}
field={:last_name}
label={gettext("Last name")}
sort_field={@sort_field}
sort_order={@sort_order}
/>
"""
}
>
{member.last_name}
</:col>
<:col
:let={member}

View file

@ -0,0 +1,232 @@
defmodule MvWeb.MemberLive.Index.FieldSelection do
@moduledoc """
Handles user-specific field selection persistence and URL parameter parsing.
This module manages:
- Reading/writing field selection from cookies (persistent storage)
- Reading/writing field selection from session (temporary storage)
- Parsing field selection from URL parameters
- Merging multiple sources with priority: URL > Session > Cookie
## Data Format
Field selection is stored as a map:
```elixir
%{
"first_name" => true,
"email" => true,
"street" => false,
"custom_field_abc-123" => true
}
```
## Cookie/Session Format
Stored as JSON string: `{"first_name":true,"email":true}`
## URL Format
Comma-separated list: `?fields=first_name,email,custom_field_abc-123`
"""
@cookie_name "member_field_selection"
@cookie_max_age 365 * 24 * 60 * 60
@session_key "member_field_selection"
@doc """
Reads field selection from session.
Returns a map of field names (strings) to boolean visibility values.
Returns empty map if no selection is stored.
"""
@spec get_from_session(map()) :: %{String.t() => boolean()}
def get_from_session(session) when is_map(session) do
case Map.get(session, @session_key) do
nil -> %{}
json_string when is_binary(json_string) -> parse_json(json_string)
_ -> %{}
end
end
def get_from_session(_), do: %{}
@doc """
Saves field selection to session.
Converts the map to JSON string and stores it in the session.
"""
@spec save_to_session(map(), %{String.t() => boolean()}) :: map()
def save_to_session(session, selection) when is_map(selection) do
json_string = Jason.encode!(selection)
Map.put(session, @session_key, json_string)
end
def save_to_session(session, _), do: session
@doc """
Reads field selection from cookie.
Returns a map of field names (strings) to boolean visibility values.
Returns empty map if no cookie is present.
Note: This function requires the connection to have cookies parsed.
In LiveView, cookies are typically accessed via get_connect_info.
"""
@spec get_from_cookie(Plug.Conn.t()) :: %{String.t() => boolean()}
def get_from_cookie(conn) do
case Plug.Conn.get_req_header(conn, "cookie") do
nil ->
%{}
cookie_header ->
# Parse cookies manually from header
cookies = parse_cookie_header(cookie_header)
case Map.get(cookies, @cookie_name) do
nil -> %{}
json_string when is_binary(json_string) -> parse_json(json_string)
_ -> %{}
end
end
end
# Parses cookie header string into a map
defp parse_cookie_header(cookie_header) when is_binary(cookie_header) do
cookie_header
|> String.split(";")
|> Enum.map(&String.trim/1)
|> Enum.map(&String.split(&1, "=", parts: 2))
|> Enum.reduce(%{}, fn
[key, value], acc -> Map.put(acc, key, URI.decode(value))
[key], acc -> Map.put(acc, key, "")
_, acc -> acc
end)
end
defp parse_cookie_header(_), do: %{}
@doc """
Saves field selection to cookie.
Sets a persistent cookie with the field selection as JSON.
"""
@spec save_to_cookie(Plug.Conn.t(), %{String.t() => boolean()}) :: Plug.Conn.t()
def save_to_cookie(conn, selection) when is_map(selection) do
json_string = Jason.encode!(selection)
secure = Application.get_env(:mv, :use_secure_cookies, false)
Plug.Conn.put_resp_cookie(conn, @cookie_name, json_string,
max_age: @cookie_max_age,
same_site: "Lax",
http_only: true,
secure: secure
)
end
def save_to_cookie(conn, _), do: conn
@doc """
Parses field selection from URL parameters.
Expects a comma-separated list of field names in the `fields` parameter.
All fields in the list are set to `true` (visible).
## Examples
iex> parse_from_url(%{"fields" => "first_name,email"})
%{"first_name" => true, "email" => true}
iex> parse_from_url(%{"fields" => "custom_field_abc-123"})
%{"custom_field_abc-123" => true}
iex> parse_from_url(%{})
%{}
"""
@spec parse_from_url(map()) :: %{String.t() => boolean()}
def parse_from_url(params) when is_map(params) do
case Map.get(params, "fields") do
nil -> %{}
"" -> %{}
fields_string when is_binary(fields_string) -> parse_fields_string(fields_string)
_ -> %{}
end
end
def parse_from_url(_), do: %{}
@doc """
Merges multiple field selection sources with priority.
Priority order (highest to lowest):
1. URL parameters
2. Session
3. Cookie
Later sources override earlier ones for the same field.
## Examples
iex> merge_sources(%{"first_name" => true}, %{"email" => true}, %{"street" => true})
%{"first_name" => true, "email" => true, "street" => true}
iex> merge_sources(%{"first_name" => false}, %{"first_name" => true}, %{})
%{"first_name" => false} # URL has priority
"""
@spec merge_sources(
%{String.t() => boolean()},
%{String.t() => boolean()},
%{String.t() => boolean()}
) :: %{String.t() => boolean()}
def merge_sources(url_selection, session_selection, cookie_selection) do
%{}
|> Map.merge(cookie_selection)
|> Map.merge(session_selection)
|> Map.merge(url_selection)
end
@doc """
Converts field selection map to URL parameter string.
Returns a comma-separated string of visible fields (where value is `true`).
## Examples
iex> to_url_param(%{"first_name" => true, "email" => true, "street" => false})
"first_name,email"
"""
@spec to_url_param(%{String.t() => boolean()}) :: String.t()
def to_url_param(selection) when is_map(selection) do
selection
|> Enum.filter(fn {_field, visible} -> visible end)
|> Enum.map(fn {field, _visible} -> field end)
|> Enum.join(",")
end
def to_url_param(_), do: ""
# Parses a JSON string into a map, handling errors gracefully
defp parse_json(json_string) when is_binary(json_string) do
case Jason.decode(json_string) do
{:ok, decoded} when is_map(decoded) ->
# Ensure all values are booleans
Enum.reduce(decoded, %{}, fn
{key, value} when is_boolean(value) -> {key, value}
{key, _value} -> {key, true}
end)
_ ->
%{}
end
end
defp parse_json(_), do: %{}
# Parses a comma-separated string of field names
defp parse_fields_string(fields_string) do
fields_string
|> String.split(",")
|> Enum.map(&String.trim/1)
|> Enum.filter(&(&1 != ""))
|> Enum.reduce(%{}, fn field, acc -> Map.put(acc, field, true) end)
end
end

View file

@ -0,0 +1,235 @@
defmodule MvWeb.MemberLive.Index.FieldVisibility do
@moduledoc """
Manages field visibility by merging user-specific selection with global settings.
This module handles:
- Getting all available fields (member fields + custom fields)
- Merging user selection with global settings (user selection takes priority)
- Falling back to global settings when no user selection exists
- Converting between different field name formats (atoms vs strings)
## Field Naming Convention
- **Member Fields**: Atoms (e.g., `:first_name`, `:email`)
- **Custom Fields**: Strings with format `"custom_field_<id>"` (e.g., `"custom_field_abc-123"`)
## Priority Order
1. User-specific selection (from URL/Session/Cookie)
2. Global settings (from database)
3. Default (all fields visible)
"""
@doc """
Gets all available fields for selection.
Returns a list of field identifiers:
- Member fields as atoms (e.g., `:first_name`, `:email`)
- Custom fields as strings (e.g., `"custom_field_abc-123"`)
## Parameters
- `custom_fields` - List of CustomField resources that are available
## Returns
List of field identifiers (atoms and strings)
"""
@spec get_all_available_fields([struct()]) :: [atom() | String.t()]
def get_all_available_fields(custom_fields) do
member_fields = Mv.Constants.member_fields()
custom_field_names = Enum.map(custom_fields, &"custom_field_#{&1.id}")
member_fields ++ custom_field_names
end
@doc """
Merges user field selection with global settings.
User selection takes priority over global settings. If a field is not in the
user selection, the global setting is used. If a field is not in global settings,
it defaults to `true` (visible).
## Parameters
- `user_selection` - Map of field names (strings) to boolean visibility
- `global_settings` - Settings struct with `member_field_visibility` field
- `custom_fields` - List of CustomField resources
## Returns
Map of field names (strings) to boolean visibility values
## Examples
iex> user_selection = %{"first_name" => false}
iex> settings = %{member_field_visibility: %{first_name: true, email: true}}
iex> merge_with_global_settings(user_selection, settings, [])
%{"first_name" => false, "email" => true} # User selection overrides global
"""
@spec merge_with_global_settings(
%{String.t() => boolean()},
map(),
[struct()]
) :: %{String.t() => boolean()}
def merge_with_global_settings(user_selection, global_settings, custom_fields) do
all_fields = get_all_available_fields(custom_fields)
global_visibility = get_global_visibility_map(global_settings, custom_fields)
Enum.reduce(all_fields, %{}, fn field, acc ->
field_string = field_to_string(field)
visibility =
case Map.get(user_selection, field_string) do
nil -> Map.get(global_visibility, field_string, true)
user_value -> user_value
end
Map.put(acc, field_string, visibility)
end)
end
@doc """
Gets the list of visible fields from a field selection map.
Returns only fields where visibility is `true`.
## Parameters
- `field_selection` - Map of field names to boolean visibility
## Returns
List of field identifiers (atoms for member fields, strings for custom fields)
## Examples
iex> selection = %{"first_name" => true, "email" => false, "street" => true}
iex> get_visible_fields(selection)
[:first_name, :street]
"""
@spec get_visible_fields(%{String.t() => boolean()}) :: [atom() | String.t()]
def get_visible_fields(field_selection) when is_map(field_selection) do
field_selection
|> Enum.filter(fn {_field, visible} -> visible end)
|> Enum.map(fn {field_string, _visible} -> to_field_identifier(field_string) end)
end
def get_visible_fields(_), do: []
@doc """
Gets visible member fields from field selection.
Returns only member fields (atoms) that are visible.
## Examples
iex> selection = %{"first_name" => true, "email" => true, "custom_field_123" => true}
iex> get_visible_member_fields(selection)
[:first_name, :email]
"""
@spec get_visible_member_fields(%{String.t() => boolean()}) :: [atom()]
def get_visible_member_fields(field_selection) when is_map(field_selection) do
member_fields = Mv.Constants.member_fields()
field_selection
|> Enum.filter(fn {field_string, visible} ->
field_atom = to_field_identifier(field_string)
visible && field_atom in member_fields
end)
|> Enum.map(fn {field_string, _visible} -> to_field_identifier(field_string) end)
end
def get_visible_member_fields(_), do: []
@doc """
Gets visible custom fields from field selection.
Returns only custom field identifiers (strings) that are visible.
## Examples
iex> selection = %{"first_name" => true, "custom_field_123" => true, "custom_field_456" => false}
iex> get_visible_custom_fields(selection)
["custom_field_123"]
"""
@spec get_visible_custom_fields(%{String.t() => boolean()}) :: [String.t()]
def get_visible_custom_fields(field_selection) when is_map(field_selection) do
field_selection
|> Enum.filter(fn {field_string, visible} ->
visible && String.starts_with?(field_string, "custom_field_")
end)
|> Enum.map(fn {field_string, _visible} -> field_string end)
end
def get_visible_custom_fields(_), do: []
# Gets global visibility map from settings
defp get_global_visibility_map(settings, custom_fields) do
member_visibility = get_member_field_visibility_from_settings(settings)
custom_field_visibility = get_custom_field_visibility(custom_fields)
Map.merge(member_visibility, custom_field_visibility)
end
# Gets member field visibility from settings
defp get_member_field_visibility_from_settings(settings) do
visibility_config =
normalize_visibility_config(Map.get(settings, :member_field_visibility, %{}))
member_fields = Mv.Constants.member_fields()
Enum.reduce(member_fields, %{}, fn field, acc ->
field_string = Atom.to_string(field)
show_in_overview = Map.get(visibility_config, field, true)
Map.put(acc, field_string, show_in_overview)
end)
end
# Gets custom field visibility (all custom fields with show_in_overview=true are visible)
defp get_custom_field_visibility(custom_fields) do
Enum.reduce(custom_fields, %{}, fn custom_field, acc ->
field_string = "custom_field_#{custom_field.id}"
visible = Map.get(custom_field, :show_in_overview, true)
Map.put(acc, field_string, visible)
end)
end
# Normalizes visibility config map keys from strings to atoms
defp normalize_visibility_config(config) when is_map(config) do
Enum.reduce(config, %{}, fn
{key, value}, acc when is_atom(key) ->
Map.put(acc, key, value)
{key, value}, acc when is_binary(key) ->
try do
atom_key = String.to_existing_atom(key)
Map.put(acc, atom_key, value)
rescue
ArgumentError -> acc
end
_, acc ->
acc
end)
end
defp normalize_visibility_config(_), do: %{}
# Converts field string to atom (for member fields) or keeps as string (for custom fields)
defp to_field_identifier(field_string) when is_binary(field_string) do
if String.starts_with?(field_string, "custom_field_") do
field_string
else
try do
String.to_existing_atom(field_string)
rescue
ArgumentError -> field_string
end
end
end
# Converts field identifier to string
defp field_to_string(field) when is_atom(field), do: Atom.to_string(field)
defp field_to_string(field) when is_binary(field), do: field
end