feat: adds field visibility dropdown live component
This commit is contained in:
parent
f709edcf6f
commit
8b445cec48
6 changed files with 967 additions and 53 deletions
232
lib/mv_web/live/member_live/index/field_selection.ex
Normal file
232
lib/mv_web/live/member_live/index/field_selection.ex
Normal file
|
|
@ -0,0 +1,232 @@
|
|||
defmodule MvWeb.MemberLive.Index.FieldSelection do
|
||||
@moduledoc """
|
||||
Handles user-specific field selection persistence and URL parameter parsing.
|
||||
|
||||
This module manages:
|
||||
- Reading/writing field selection from cookies (persistent storage)
|
||||
- Reading/writing field selection from session (temporary storage)
|
||||
- Parsing field selection from URL parameters
|
||||
- Merging multiple sources with priority: URL > Session > Cookie
|
||||
|
||||
## Data Format
|
||||
|
||||
Field selection is stored as a map:
|
||||
```elixir
|
||||
%{
|
||||
"first_name" => true,
|
||||
"email" => true,
|
||||
"street" => false,
|
||||
"custom_field_abc-123" => true
|
||||
}
|
||||
```
|
||||
|
||||
## Cookie/Session Format
|
||||
|
||||
Stored as JSON string: `{"first_name":true,"email":true}`
|
||||
|
||||
## URL Format
|
||||
|
||||
Comma-separated list: `?fields=first_name,email,custom_field_abc-123`
|
||||
"""
|
||||
|
||||
@cookie_name "member_field_selection"
|
||||
@cookie_max_age 365 * 24 * 60 * 60
|
||||
@session_key "member_field_selection"
|
||||
|
||||
@doc """
|
||||
Reads field selection from session.
|
||||
|
||||
Returns a map of field names (strings) to boolean visibility values.
|
||||
Returns empty map if no selection is stored.
|
||||
"""
|
||||
@spec get_from_session(map()) :: %{String.t() => boolean()}
|
||||
def get_from_session(session) when is_map(session) do
|
||||
case Map.get(session, @session_key) do
|
||||
nil -> %{}
|
||||
json_string when is_binary(json_string) -> parse_json(json_string)
|
||||
_ -> %{}
|
||||
end
|
||||
end
|
||||
|
||||
def get_from_session(_), do: %{}
|
||||
|
||||
@doc """
|
||||
Saves field selection to session.
|
||||
|
||||
Converts the map to JSON string and stores it in the session.
|
||||
"""
|
||||
@spec save_to_session(map(), %{String.t() => boolean()}) :: map()
|
||||
def save_to_session(session, selection) when is_map(selection) do
|
||||
json_string = Jason.encode!(selection)
|
||||
Map.put(session, @session_key, json_string)
|
||||
end
|
||||
|
||||
def save_to_session(session, _), do: session
|
||||
|
||||
@doc """
|
||||
Reads field selection from cookie.
|
||||
|
||||
Returns a map of field names (strings) to boolean visibility values.
|
||||
Returns empty map if no cookie is present.
|
||||
|
||||
Note: This function requires the connection to have cookies parsed.
|
||||
In LiveView, cookies are typically accessed via get_connect_info.
|
||||
"""
|
||||
@spec get_from_cookie(Plug.Conn.t()) :: %{String.t() => boolean()}
|
||||
def get_from_cookie(conn) do
|
||||
case Plug.Conn.get_req_header(conn, "cookie") do
|
||||
nil ->
|
||||
%{}
|
||||
|
||||
cookie_header ->
|
||||
# Parse cookies manually from header
|
||||
cookies = parse_cookie_header(cookie_header)
|
||||
|
||||
case Map.get(cookies, @cookie_name) do
|
||||
nil -> %{}
|
||||
json_string when is_binary(json_string) -> parse_json(json_string)
|
||||
_ -> %{}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Parses cookie header string into a map
|
||||
defp parse_cookie_header(cookie_header) when is_binary(cookie_header) do
|
||||
cookie_header
|
||||
|> String.split(";")
|
||||
|> Enum.map(&String.trim/1)
|
||||
|> Enum.map(&String.split(&1, "=", parts: 2))
|
||||
|> Enum.reduce(%{}, fn
|
||||
[key, value], acc -> Map.put(acc, key, URI.decode(value))
|
||||
[key], acc -> Map.put(acc, key, "")
|
||||
_, acc -> acc
|
||||
end)
|
||||
end
|
||||
|
||||
defp parse_cookie_header(_), do: %{}
|
||||
|
||||
@doc """
|
||||
Saves field selection to cookie.
|
||||
|
||||
Sets a persistent cookie with the field selection as JSON.
|
||||
"""
|
||||
@spec save_to_cookie(Plug.Conn.t(), %{String.t() => boolean()}) :: Plug.Conn.t()
|
||||
def save_to_cookie(conn, selection) when is_map(selection) do
|
||||
json_string = Jason.encode!(selection)
|
||||
secure = Application.get_env(:mv, :use_secure_cookies, false)
|
||||
|
||||
Plug.Conn.put_resp_cookie(conn, @cookie_name, json_string,
|
||||
max_age: @cookie_max_age,
|
||||
same_site: "Lax",
|
||||
http_only: true,
|
||||
secure: secure
|
||||
)
|
||||
end
|
||||
|
||||
def save_to_cookie(conn, _), do: conn
|
||||
|
||||
@doc """
|
||||
Parses field selection from URL parameters.
|
||||
|
||||
Expects a comma-separated list of field names in the `fields` parameter.
|
||||
All fields in the list are set to `true` (visible).
|
||||
|
||||
## Examples
|
||||
|
||||
iex> parse_from_url(%{"fields" => "first_name,email"})
|
||||
%{"first_name" => true, "email" => true}
|
||||
|
||||
iex> parse_from_url(%{"fields" => "custom_field_abc-123"})
|
||||
%{"custom_field_abc-123" => true}
|
||||
|
||||
iex> parse_from_url(%{})
|
||||
%{}
|
||||
"""
|
||||
@spec parse_from_url(map()) :: %{String.t() => boolean()}
|
||||
def parse_from_url(params) when is_map(params) do
|
||||
case Map.get(params, "fields") do
|
||||
nil -> %{}
|
||||
"" -> %{}
|
||||
fields_string when is_binary(fields_string) -> parse_fields_string(fields_string)
|
||||
_ -> %{}
|
||||
end
|
||||
end
|
||||
|
||||
def parse_from_url(_), do: %{}
|
||||
|
||||
@doc """
|
||||
Merges multiple field selection sources with priority.
|
||||
|
||||
Priority order (highest to lowest):
|
||||
1. URL parameters
|
||||
2. Session
|
||||
3. Cookie
|
||||
|
||||
Later sources override earlier ones for the same field.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> merge_sources(%{"first_name" => true}, %{"email" => true}, %{"street" => true})
|
||||
%{"first_name" => true, "email" => true, "street" => true}
|
||||
|
||||
iex> merge_sources(%{"first_name" => false}, %{"first_name" => true}, %{})
|
||||
%{"first_name" => false} # URL has priority
|
||||
"""
|
||||
@spec merge_sources(
|
||||
%{String.t() => boolean()},
|
||||
%{String.t() => boolean()},
|
||||
%{String.t() => boolean()}
|
||||
) :: %{String.t() => boolean()}
|
||||
def merge_sources(url_selection, session_selection, cookie_selection) do
|
||||
%{}
|
||||
|> Map.merge(cookie_selection)
|
||||
|> Map.merge(session_selection)
|
||||
|> Map.merge(url_selection)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Converts field selection map to URL parameter string.
|
||||
|
||||
Returns a comma-separated string of visible fields (where value is `true`).
|
||||
|
||||
## Examples
|
||||
|
||||
iex> to_url_param(%{"first_name" => true, "email" => true, "street" => false})
|
||||
"first_name,email"
|
||||
"""
|
||||
@spec to_url_param(%{String.t() => boolean()}) :: String.t()
|
||||
def to_url_param(selection) when is_map(selection) do
|
||||
selection
|
||||
|> Enum.filter(fn {_field, visible} -> visible end)
|
||||
|> Enum.map(fn {field, _visible} -> field end)
|
||||
|> Enum.join(",")
|
||||
end
|
||||
|
||||
def to_url_param(_), do: ""
|
||||
|
||||
# Parses a JSON string into a map, handling errors gracefully
|
||||
defp parse_json(json_string) when is_binary(json_string) do
|
||||
case Jason.decode(json_string) do
|
||||
{:ok, decoded} when is_map(decoded) ->
|
||||
# Ensure all values are booleans
|
||||
Enum.reduce(decoded, %{}, fn
|
||||
{key, value} when is_boolean(value) -> {key, value}
|
||||
{key, _value} -> {key, true}
|
||||
end)
|
||||
|
||||
_ ->
|
||||
%{}
|
||||
end
|
||||
end
|
||||
|
||||
defp parse_json(_), do: %{}
|
||||
|
||||
# Parses a comma-separated string of field names
|
||||
defp parse_fields_string(fields_string) do
|
||||
fields_string
|
||||
|> String.split(",")
|
||||
|> Enum.map(&String.trim/1)
|
||||
|> Enum.filter(&(&1 != ""))
|
||||
|> Enum.reduce(%{}, fn field, acc -> Map.put(acc, field, true) end)
|
||||
end
|
||||
end
|
||||
235
lib/mv_web/live/member_live/index/field_visibility.ex
Normal file
235
lib/mv_web/live/member_live/index/field_visibility.ex
Normal file
|
|
@ -0,0 +1,235 @@
|
|||
defmodule MvWeb.MemberLive.Index.FieldVisibility do
|
||||
@moduledoc """
|
||||
Manages field visibility by merging user-specific selection with global settings.
|
||||
|
||||
This module handles:
|
||||
- Getting all available fields (member fields + custom fields)
|
||||
- Merging user selection with global settings (user selection takes priority)
|
||||
- Falling back to global settings when no user selection exists
|
||||
- Converting between different field name formats (atoms vs strings)
|
||||
|
||||
## Field Naming Convention
|
||||
|
||||
- **Member Fields**: Atoms (e.g., `:first_name`, `:email`)
|
||||
- **Custom Fields**: Strings with format `"custom_field_<id>"` (e.g., `"custom_field_abc-123"`)
|
||||
|
||||
## Priority Order
|
||||
|
||||
1. User-specific selection (from URL/Session/Cookie)
|
||||
2. Global settings (from database)
|
||||
3. Default (all fields visible)
|
||||
"""
|
||||
|
||||
@doc """
|
||||
Gets all available fields for selection.
|
||||
|
||||
Returns a list of field identifiers:
|
||||
- Member fields as atoms (e.g., `:first_name`, `:email`)
|
||||
- Custom fields as strings (e.g., `"custom_field_abc-123"`)
|
||||
|
||||
## Parameters
|
||||
|
||||
- `custom_fields` - List of CustomField resources that are available
|
||||
|
||||
## Returns
|
||||
|
||||
List of field identifiers (atoms and strings)
|
||||
"""
|
||||
@spec get_all_available_fields([struct()]) :: [atom() | String.t()]
|
||||
def get_all_available_fields(custom_fields) do
|
||||
member_fields = Mv.Constants.member_fields()
|
||||
custom_field_names = Enum.map(custom_fields, &"custom_field_#{&1.id}")
|
||||
|
||||
member_fields ++ custom_field_names
|
||||
end
|
||||
|
||||
@doc """
|
||||
Merges user field selection with global settings.
|
||||
|
||||
User selection takes priority over global settings. If a field is not in the
|
||||
user selection, the global setting is used. If a field is not in global settings,
|
||||
it defaults to `true` (visible).
|
||||
|
||||
## Parameters
|
||||
|
||||
- `user_selection` - Map of field names (strings) to boolean visibility
|
||||
- `global_settings` - Settings struct with `member_field_visibility` field
|
||||
- `custom_fields` - List of CustomField resources
|
||||
|
||||
## Returns
|
||||
|
||||
Map of field names (strings) to boolean visibility values
|
||||
|
||||
## Examples
|
||||
|
||||
iex> user_selection = %{"first_name" => false}
|
||||
iex> settings = %{member_field_visibility: %{first_name: true, email: true}}
|
||||
iex> merge_with_global_settings(user_selection, settings, [])
|
||||
%{"first_name" => false, "email" => true} # User selection overrides global
|
||||
"""
|
||||
@spec merge_with_global_settings(
|
||||
%{String.t() => boolean()},
|
||||
map(),
|
||||
[struct()]
|
||||
) :: %{String.t() => boolean()}
|
||||
def merge_with_global_settings(user_selection, global_settings, custom_fields) do
|
||||
all_fields = get_all_available_fields(custom_fields)
|
||||
global_visibility = get_global_visibility_map(global_settings, custom_fields)
|
||||
|
||||
Enum.reduce(all_fields, %{}, fn field, acc ->
|
||||
field_string = field_to_string(field)
|
||||
|
||||
visibility =
|
||||
case Map.get(user_selection, field_string) do
|
||||
nil -> Map.get(global_visibility, field_string, true)
|
||||
user_value -> user_value
|
||||
end
|
||||
|
||||
Map.put(acc, field_string, visibility)
|
||||
end)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Gets the list of visible fields from a field selection map.
|
||||
|
||||
Returns only fields where visibility is `true`.
|
||||
|
||||
## Parameters
|
||||
|
||||
- `field_selection` - Map of field names to boolean visibility
|
||||
|
||||
## Returns
|
||||
|
||||
List of field identifiers (atoms for member fields, strings for custom fields)
|
||||
|
||||
## Examples
|
||||
|
||||
iex> selection = %{"first_name" => true, "email" => false, "street" => true}
|
||||
iex> get_visible_fields(selection)
|
||||
[:first_name, :street]
|
||||
"""
|
||||
@spec get_visible_fields(%{String.t() => boolean()}) :: [atom() | String.t()]
|
||||
def get_visible_fields(field_selection) when is_map(field_selection) do
|
||||
field_selection
|
||||
|> Enum.filter(fn {_field, visible} -> visible end)
|
||||
|> Enum.map(fn {field_string, _visible} -> to_field_identifier(field_string) end)
|
||||
end
|
||||
|
||||
def get_visible_fields(_), do: []
|
||||
|
||||
@doc """
|
||||
Gets visible member fields from field selection.
|
||||
|
||||
Returns only member fields (atoms) that are visible.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> selection = %{"first_name" => true, "email" => true, "custom_field_123" => true}
|
||||
iex> get_visible_member_fields(selection)
|
||||
[:first_name, :email]
|
||||
"""
|
||||
@spec get_visible_member_fields(%{String.t() => boolean()}) :: [atom()]
|
||||
def get_visible_member_fields(field_selection) when is_map(field_selection) do
|
||||
member_fields = Mv.Constants.member_fields()
|
||||
|
||||
field_selection
|
||||
|> Enum.filter(fn {field_string, visible} ->
|
||||
field_atom = to_field_identifier(field_string)
|
||||
visible && field_atom in member_fields
|
||||
end)
|
||||
|> Enum.map(fn {field_string, _visible} -> to_field_identifier(field_string) end)
|
||||
end
|
||||
|
||||
def get_visible_member_fields(_), do: []
|
||||
|
||||
@doc """
|
||||
Gets visible custom fields from field selection.
|
||||
|
||||
Returns only custom field identifiers (strings) that are visible.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> selection = %{"first_name" => true, "custom_field_123" => true, "custom_field_456" => false}
|
||||
iex> get_visible_custom_fields(selection)
|
||||
["custom_field_123"]
|
||||
"""
|
||||
@spec get_visible_custom_fields(%{String.t() => boolean()}) :: [String.t()]
|
||||
def get_visible_custom_fields(field_selection) when is_map(field_selection) do
|
||||
field_selection
|
||||
|> Enum.filter(fn {field_string, visible} ->
|
||||
visible && String.starts_with?(field_string, "custom_field_")
|
||||
end)
|
||||
|> Enum.map(fn {field_string, _visible} -> field_string end)
|
||||
end
|
||||
|
||||
def get_visible_custom_fields(_), do: []
|
||||
|
||||
# Gets global visibility map from settings
|
||||
defp get_global_visibility_map(settings, custom_fields) do
|
||||
member_visibility = get_member_field_visibility_from_settings(settings)
|
||||
custom_field_visibility = get_custom_field_visibility(custom_fields)
|
||||
|
||||
Map.merge(member_visibility, custom_field_visibility)
|
||||
end
|
||||
|
||||
# Gets member field visibility from settings
|
||||
defp get_member_field_visibility_from_settings(settings) do
|
||||
visibility_config =
|
||||
normalize_visibility_config(Map.get(settings, :member_field_visibility, %{}))
|
||||
|
||||
member_fields = Mv.Constants.member_fields()
|
||||
|
||||
Enum.reduce(member_fields, %{}, fn field, acc ->
|
||||
field_string = Atom.to_string(field)
|
||||
show_in_overview = Map.get(visibility_config, field, true)
|
||||
Map.put(acc, field_string, show_in_overview)
|
||||
end)
|
||||
end
|
||||
|
||||
# Gets custom field visibility (all custom fields with show_in_overview=true are visible)
|
||||
defp get_custom_field_visibility(custom_fields) do
|
||||
Enum.reduce(custom_fields, %{}, fn custom_field, acc ->
|
||||
field_string = "custom_field_#{custom_field.id}"
|
||||
visible = Map.get(custom_field, :show_in_overview, true)
|
||||
Map.put(acc, field_string, visible)
|
||||
end)
|
||||
end
|
||||
|
||||
# Normalizes visibility config map keys from strings to atoms
|
||||
defp normalize_visibility_config(config) when is_map(config) do
|
||||
Enum.reduce(config, %{}, fn
|
||||
{key, value}, acc when is_atom(key) ->
|
||||
Map.put(acc, key, value)
|
||||
|
||||
{key, value}, acc when is_binary(key) ->
|
||||
try do
|
||||
atom_key = String.to_existing_atom(key)
|
||||
Map.put(acc, atom_key, value)
|
||||
rescue
|
||||
ArgumentError -> acc
|
||||
end
|
||||
|
||||
_, acc ->
|
||||
acc
|
||||
end)
|
||||
end
|
||||
|
||||
defp normalize_visibility_config(_), do: %{}
|
||||
|
||||
# Converts field string to atom (for member fields) or keeps as string (for custom fields)
|
||||
defp to_field_identifier(field_string) when is_binary(field_string) do
|
||||
if String.starts_with?(field_string, "custom_field_") do
|
||||
field_string
|
||||
else
|
||||
try do
|
||||
String.to_existing_atom(field_string)
|
||||
rescue
|
||||
ArgumentError -> field_string
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Converts field identifier to string
|
||||
defp field_to_string(field) when is_atom(field), do: Atom.to_string(field)
|
||||
defp field_to_string(field) when is_binary(field), do: field
|
||||
end
|
||||
Loading…
Add table
Add a link
Reference in a new issue