refactor: remove code duplication using helper modules

This commit is contained in:
carla 2026-01-08 11:37:07 +01:00
parent 4a1042ab1a
commit 30c43271ea
4 changed files with 45 additions and 220 deletions

View file

@ -18,6 +18,8 @@ defmodule MvWeb.Components.FieldVisibilityDropdownComponent do
use MvWeb, :live_component use MvWeb, :live_component
alias MvWeb.Translations.MemberFields
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# UPDATE # UPDATE
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -66,7 +68,7 @@ defmodule MvWeb.Components.FieldVisibilityDropdownComponent do
<.dropdown_menu <.dropdown_menu
id="field-visibility-menu" id="field-visibility-menu"
icon="hero-adjustments-horizontal" icon="hero-adjustments-horizontal"
button_label={gettext("Columns")} button_label={gettext("Show/Hide Columns")}
items={@all_items} items={@all_items}
checkboxes={true} checkboxes={true}
selected={@selected_fields} selected={@selected_fields}
@ -153,12 +155,12 @@ defmodule MvWeb.Components.FieldVisibilityDropdownComponent do
defp field_to_string(field) when is_binary(field), do: field defp field_to_string(field) when is_binary(field), do: field
defp format_field_label(field) when is_atom(field) do defp format_field_label(field) when is_atom(field) do
MvWeb.Translations.MemberFields.label(field) MemberFields.label(field)
end end
defp format_field_label(field) when is_binary(field) do defp format_field_label(field) when is_binary(field) do
case safe_to_existing_atom(field) do case safe_to_existing_atom(field) do
{:ok, atom} -> MvWeb.Translations.MemberFields.label(atom) {:ok, atom} -> MemberFields.label(atom)
:error -> fallback_label(field) :error -> fallback_label(field)
end end
end end

View file

@ -3,22 +3,28 @@ defmodule MvWeb.MemberFieldLive.FormComponent do
LiveComponent form for editing member field properties (embedded in settings). LiveComponent form for editing member field properties (embedded in settings).
## Features ## Features
- Edit member field properties (name, value type, description, immutable, required, show in overview) - Edit member field visibility (show_in_overview)
- Display member field information from Member Resource - Display member field information from Member Resource (read-only)
- Restrict editing for email field (only show_in_overview can be changed) - Restrict editing for email field (only show_in_overview can be changed)
- Real-time validation - Real-time validation
- Updates Settings.member_field_visibility - Updates Settings.member_field_visibility atomically
## Props ## Props
- `member_field` - The member field atom to edit (e.g., :first_name, :email) - `member_field` - The member field atom to edit (e.g., :first_name, :email)
- `settings` - The current Settings resource - `settings` - The current Settings resource
- `on_save` - Callback function to call when form is saved - `on_save` - Callback function to call when form is saved
- `on_cancel` - Callback function to call when form is cancelled - `on_cancel` - Callback function to call when form is cancelled
## Note
Member fields are technical fields that cannot be changed (name, value_type, description, required).
Only the visibility (show_in_overview) can be modified.
""" """
use MvWeb, :live_component use MvWeb, :live_component
alias Mv.Helpers.TypeParsers
alias Mv.Membership alias Mv.Membership
alias MvWeb.Translations.FieldTypes alias Mv.Membership.Helpers.VisibilityConfig
alias MvWeb.Helpers.FieldTypeFormatter
alias MvWeb.Translations.MemberFields alias MvWeb.Translations.MemberFields
@required_fields [:first_name, :last_name, :email] @required_fields [:first_name, :last_name, :email]
@ -39,7 +45,7 @@ defmodule MvWeb.MemberFieldLive.FormComponent do
type="button" type="button"
phx-click="cancel" phx-click="cancel"
phx-target={@myself} phx-target={@myself}
aria-label={gettext("Back to member field overview")} aria-label={gettext("Back to Settings")}
> >
<.icon name="hero-arrow-left" class="w-4 h-4" /> <.icon name="hero-arrow-left" class="w-4 h-4" />
</.button> </.button>
@ -102,7 +108,7 @@ defmodule MvWeb.MemberFieldLive.FormComponent do
type="text" type="text"
name={@form[:value_type].name} name={@form[:value_type].name}
id={@form[:value_type].id} id={@form[:value_type].id}
value={format_value_type(@field_attributes.value_type)} value={FieldTypeFormatter.format(@field_attributes.value_type)}
disabled disabled
readonly readonly
class="w-full input" class="w-full input"
@ -148,47 +154,6 @@ defmodule MvWeb.MemberFieldLive.FormComponent do
readonly={@is_email_field?} readonly={@is_email_field?}
/> />
<div
:if={@is_email_field?}
class="tooltip tooltip-right"
data-tip={gettext("This is a technical field and cannot be changed")}
aria-label={gettext("This is a technical field and cannot be changed")}
>
<fieldset class="mb-2 fieldset">
<label>
<input type="hidden" name={@form[:immutable].name} value="false" disabled />
<span class="label flex items-center gap-2">
<input
type="checkbox"
name={@form[:immutable].name}
id={@form[:immutable].id}
value="true"
checked={@form[:immutable].value}
disabled
readonly
class="checkbox checkbox-sm"
/>
<span class="flex items-center gap-2">
{gettext("Immutable")}
<.icon
name="hero-information-circle"
class="w-4 h-4 text-base-content/60 cursor-help"
aria-hidden="true"
/>
</span>
</span>
</label>
</fieldset>
</div>
<.input
:if={not @is_email_field?}
field={@form[:immutable]}
type="checkbox"
label={gettext("Immutable")}
disabled={@is_email_field?}
readonly={@is_email_field?}
/>
<div <div
:if={@is_email_field?} :if={@is_email_field?}
class="tooltip tooltip-right" class="tooltip tooltip-right"
@ -266,11 +231,13 @@ defmodule MvWeb.MemberFieldLive.FormComponent do
updated_params = updated_params =
member_field_params member_field_params
|> Map.put("show_in_overview", parse_boolean(member_field_params["show_in_overview"])) |> Map.put(
"show_in_overview",
TypeParsers.parse_boolean(member_field_params["show_in_overview"])
)
|> Map.put("name", form.source["name"]) |> Map.put("name", form.source["name"])
|> Map.put("value_type", form.source["value_type"]) |> Map.put("value_type", form.source["value_type"])
|> Map.put("description", form.source["description"]) |> Map.put("description", form.source["description"])
|> Map.put("immutable", form.source["immutable"])
|> Map.put("required", form.source["required"]) |> Map.put("required", form.source["required"])
updated_form = updated_form =
@ -284,29 +251,15 @@ defmodule MvWeb.MemberFieldLive.FormComponent do
@impl true @impl true
def handle_event("save", %{"member_field" => member_field_params}, socket) do def handle_event("save", %{"member_field" => member_field_params}, socket) do
# Only show_in_overview can be changed for member fields # Only show_in_overview can be changed for member fields
show_in_overview = parse_boolean(member_field_params["show_in_overview"]) show_in_overview = TypeParsers.parse_boolean(member_field_params["show_in_overview"])
# Get current visibility config and update only the current field
current_visibility = socket.assigns.settings.member_field_visibility || %{}
field_string = Atom.to_string(socket.assigns.member_field) field_string = Atom.to_string(socket.assigns.member_field)
# Normalize keys to strings # Use atomic action to update only this single field
normalized_visibility = # This prevents lost updates in concurrent scenarios
Enum.reduce(current_visibility, %{}, fn case Membership.update_single_member_field_visibility(
{key, value}, acc when is_atom(key) ->
Map.put(acc, Atom.to_string(key), value)
{key, value}, acc when is_binary(key) ->
Map.put(acc, key, value)
end)
# Update the specific field
updated_visibility = Map.put(normalized_visibility, field_string, show_in_overview)
# Update settings with new visibility
case Membership.update_member_field_visibility(
socket.assigns.settings, socket.assigns.settings,
updated_visibility field: field_string,
show_in_overview: show_in_overview
) do ) do
{:ok, _updated_settings} -> {:ok, _updated_settings} ->
socket.assigns.on_save.(socket.assigns.member_field, "update") socket.assigns.on_save.(socket.assigns.member_field, "update")
@ -335,15 +288,15 @@ defmodule MvWeb.MemberFieldLive.FormComponent do
defp assign_form(%{assigns: %{member_field: member_field, settings: settings}} = socket) do defp assign_form(%{assigns: %{member_field: member_field, settings: settings}} = socket) do
field_attributes = get_field_attributes(member_field) field_attributes = get_field_attributes(member_field)
visibility_config = settings.member_field_visibility || %{} visibility_config = settings.member_field_visibility || %{}
normalized_config = normalize_visibility_config(visibility_config) normalized_config = VisibilityConfig.normalize(visibility_config)
show_in_overview = Map.get(normalized_config, member_field, true) show_in_overview = Map.get(normalized_config, member_field, true)
# Create a manual form structure with string keys # Create a manual form structure with string keys
# Note: immutable is not included as it's not editable for member fields
form_data = %{ form_data = %{
"name" => MemberFields.label(member_field), "name" => MemberFields.label(member_field),
"value_type" => format_value_type(field_attributes.value_type), "value_type" => FieldTypeFormatter.format(field_attributes.value_type),
"description" => field_attributes.description || "", "description" => field_attributes.description || "",
"immutable" => field_attributes.immutable,
"required" => field_attributes.required, "required" => field_attributes.required,
"show_in_overview" => show_in_overview "show_in_overview" => show_in_overview
} }
@ -355,13 +308,14 @@ defmodule MvWeb.MemberFieldLive.FormComponent do
defp get_field_attributes(field) when is_atom(field) do defp get_field_attributes(field) when is_atom(field) do
# Get attribute info from Member Resource # Get attribute info from Member Resource
case Ash.Resource.Info.attribute(Mv.Membership.Member, field) do alias Ash.Resource.Info
case Info.attribute(Mv.Membership.Member, field) do
nil -> nil ->
# Fallback for fields not in resource (shouldn't happen with Constants) # Fallback for fields not in resource (shouldn't happen with Constants)
%{ %{
value_type: :string, value_type: :string,
description: nil, description: nil,
immutable: field == :email,
required: field in @required_fields required: field in @required_fields
} }
@ -369,72 +323,11 @@ defmodule MvWeb.MemberFieldLive.FormComponent do
%{ %{
value_type: attribute.type, value_type: attribute.type,
description: nil, description: nil,
immutable: field == :email,
required: not attribute.allow_nil? required: not attribute.allow_nil?
} }
end end
end end
defp format_value_type(type) when is_atom(type) do
type_string = to_string(type)
# Check if it's an Ash type module (e.g., Ash.Type.String or Elixir.Ash.Type.String)
if String.contains?(type_string, "Ash.Type.") do
# Extract the base type name from Ash type modules
# e.g., "Elixir.Ash.Type.String" -> "String" -> :string
type_name =
type_string
|> String.split(".")
|> List.last()
|> String.downcase()
try do
type_atom = String.to_existing_atom(type_name)
FieldTypes.label(type_atom)
rescue
ArgumentError ->
# Fallback if atom doesn't exist
FieldTypes.label(:string)
end
else
# It's already an atom like :string, :boolean, :date
FieldTypes.label(type)
end
end
defp format_value_type(type) do
# Fallback for unknown types
to_string(type)
end
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: %{}
defp parse_boolean(value) when is_boolean(value), do: value
defp parse_boolean("true"), do: true
defp parse_boolean("false"), do: false
defp parse_boolean(1), do: true
defp parse_boolean(0), do: false
defp parse_boolean(_), do: false
defp format_error(%Ash.Error.Invalid{} = error) do defp format_error(%Ash.Error.Invalid{} = error) do
Ash.ErrorKind.message(error) Ash.ErrorKind.message(error)
end end

View file

@ -11,8 +11,10 @@ defmodule MvWeb.MemberFieldLive.IndexComponent do
""" """
use MvWeb, :live_component use MvWeb, :live_component
alias Ash.Resource.Info
alias Mv.Membership alias Mv.Membership
alias MvWeb.Translations.FieldTypes alias Mv.Membership.Helpers.VisibilityConfig
alias MvWeb.Helpers.FieldTypeFormatter
alias MvWeb.Translations.MemberFields alias MvWeb.Translations.MemberFields
@impl true @impl true
@ -180,11 +182,11 @@ defmodule MvWeb.MemberFieldLive.IndexComponent do
visibility_config = settings.member_field_visibility || %{} visibility_config = settings.member_field_visibility || %{}
# Normalize visibility config keys to atoms # Normalize visibility config keys to atoms
normalized_config = normalize_visibility_config(visibility_config) normalized_config = VisibilityConfig.normalize(visibility_config)
Enum.map(member_fields, fn field -> Enum.map(member_fields, fn field ->
show_in_overview = Map.get(normalized_config, field, true) show_in_overview = Map.get(normalized_config, field, true)
attribute = Ash.Resource.Info.attribute(Mv.Membership.Member, field) attribute = Info.attribute(Mv.Membership.Member, field)
%{ %{
field: field, field: field,
@ -199,68 +201,15 @@ defmodule MvWeb.MemberFieldLive.IndexComponent do
end end
defp format_value_type(field) when is_atom(field) do defp format_value_type(field) when is_atom(field) do
case Ash.Resource.Info.attribute(Mv.Membership.Member, field) do case Info.attribute(Mv.Membership.Member, field) do
nil -> FieldTypes.label(:string) nil -> FieldTypeFormatter.format(:string)
attribute -> format_value_type(attribute.type) attribute -> FieldTypeFormatter.format(attribute.type)
end end
end end
defp format_value_type(type) when is_atom(type) do
type_string = to_string(type)
# Check if it's an Ash type module (e.g., Ash.Type.String or Elixir.Ash.Type.String)
if String.contains?(type_string, "Ash.Type.") do
# Extract the base type name from Ash type modules
# e.g., "Elixir.Ash.Type.String" -> "String" -> :string
type_name =
type_string
|> String.split(".")
|> List.last()
|> String.downcase()
try do
type_atom = String.to_existing_atom(type_name)
FieldTypes.label(type_atom)
rescue
ArgumentError ->
# Fallback if atom doesn't exist
FieldTypes.label(:string)
end
else
# It's already an atom like :string, :boolean, :date
FieldTypes.label(type)
end
end
defp format_value_type(type) do
# Fallback for unknown types
to_string(type)
end
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: %{}
# Check if a field is required by checking the actual attribute definition # Check if a field is required by checking the actual attribute definition
defp required?(field) when is_atom(field) do defp required?(field) when is_atom(field) do
case Ash.Resource.Info.attribute(Mv.Membership.Member, field) do case Info.attribute(Mv.Membership.Member, field) do
nil -> false nil -> false
attribute -> not attribute.allow_nil? attribute -> not attribute.allow_nil?
end end

View file

@ -20,6 +20,8 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do
3. Default (all fields visible) 3. Default (all fields visible)
""" """
alias Mv.Membership.Helpers.VisibilityConfig
@doc """ @doc """
Gets all available fields for selection. Gets all available fields for selection.
@ -177,7 +179,7 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do
# Gets member field visibility from settings # Gets member field visibility from settings
defp get_member_field_visibility_from_settings(settings) do defp get_member_field_visibility_from_settings(settings) do
visibility_config = visibility_config =
normalize_visibility_config(Map.get(settings, :member_field_visibility, %{})) VisibilityConfig.normalize(Map.get(settings, :member_field_visibility, %{}))
member_fields = Mv.Constants.member_fields() member_fields = Mv.Constants.member_fields()
@ -201,27 +203,6 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do
end) end)
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) # 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 defp to_field_identifier(field_string) when is_binary(field_string) do
if String.starts_with?(field_string, Mv.Constants.custom_field_prefix()) do if String.starts_with?(field_string, Mv.Constants.custom_field_prefix()) do