diff --git a/lib/mv_web/live/components/field_visibility_dropdown_component.ex b/lib/mv_web/live/components/field_visibility_dropdown_component.ex index 5fc0abf..426daed 100644 --- a/lib/mv_web/live/components/field_visibility_dropdown_component.ex +++ b/lib/mv_web/live/components/field_visibility_dropdown_component.ex @@ -18,6 +18,8 @@ defmodule MvWeb.Components.FieldVisibilityDropdownComponent do use MvWeb, :live_component + alias MvWeb.Translations.MemberFields + # --------------------------------------------------------------------------- # UPDATE # --------------------------------------------------------------------------- @@ -66,7 +68,7 @@ defmodule MvWeb.Components.FieldVisibilityDropdownComponent do <.dropdown_menu id="field-visibility-menu" icon="hero-adjustments-horizontal" - button_label={gettext("Columns")} + button_label={gettext("Show/Hide Columns")} items={@all_items} checkboxes={true} selected={@selected_fields} @@ -153,12 +155,12 @@ defmodule MvWeb.Components.FieldVisibilityDropdownComponent do defp field_to_string(field) when is_binary(field), do: field defp format_field_label(field) when is_atom(field) do - MvWeb.Translations.MemberFields.label(field) + MemberFields.label(field) end defp format_field_label(field) when is_binary(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) end end diff --git a/lib/mv_web/live/member_field_live/form_component.ex b/lib/mv_web/live/member_field_live/form_component.ex index 0f0b446..1bba048 100644 --- a/lib/mv_web/live/member_field_live/form_component.ex +++ b/lib/mv_web/live/member_field_live/form_component.ex @@ -3,22 +3,28 @@ defmodule MvWeb.MemberFieldLive.FormComponent do LiveComponent form for editing member field properties (embedded in settings). ## Features - - Edit member field properties (name, value type, description, immutable, required, show in overview) - - Display member field information from Member Resource + - Edit member field visibility (show_in_overview) + - Display member field information from Member Resource (read-only) - Restrict editing for email field (only show_in_overview can be changed) - Real-time validation - - Updates Settings.member_field_visibility + - Updates Settings.member_field_visibility atomically ## Props - `member_field` - The member field atom to edit (e.g., :first_name, :email) - `settings` - The current Settings resource - `on_save` - Callback function to call when form is saved - `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 + alias Mv.Helpers.TypeParsers alias Mv.Membership - alias MvWeb.Translations.FieldTypes + alias Mv.Membership.Helpers.VisibilityConfig + alias MvWeb.Helpers.FieldTypeFormatter alias MvWeb.Translations.MemberFields @required_fields [:first_name, :last_name, :email] @@ -39,7 +45,7 @@ defmodule MvWeb.MemberFieldLive.FormComponent do type="button" phx-click="cancel" 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" /> @@ -102,7 +108,7 @@ defmodule MvWeb.MemberFieldLive.FormComponent do type="text" name={@form[:value_type].name} id={@form[:value_type].id} - value={format_value_type(@field_attributes.value_type)} + value={FieldTypeFormatter.format(@field_attributes.value_type)} disabled readonly class="w-full input" @@ -148,47 +154,6 @@ defmodule MvWeb.MemberFieldLive.FormComponent do readonly={@is_email_field?} /> -
-
- -
-
- <.input - :if={not @is_email_field?} - field={@form[:immutable]} - type="checkbox" - label={gettext("Immutable")} - disabled={@is_email_field?} - readonly={@is_email_field?} - /> -
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("value_type", form.source["value_type"]) |> Map.put("description", form.source["description"]) - |> Map.put("immutable", form.source["immutable"]) |> Map.put("required", form.source["required"]) updated_form = @@ -284,29 +251,15 @@ defmodule MvWeb.MemberFieldLive.FormComponent do @impl true def handle_event("save", %{"member_field" => member_field_params}, socket) do # Only show_in_overview can be changed for member fields - show_in_overview = 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 || %{} + show_in_overview = TypeParsers.parse_boolean(member_field_params["show_in_overview"]) field_string = Atom.to_string(socket.assigns.member_field) - # Normalize keys to strings - normalized_visibility = - Enum.reduce(current_visibility, %{}, fn - {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( + # Use atomic action to update only this single field + # This prevents lost updates in concurrent scenarios + case Membership.update_single_member_field_visibility( socket.assigns.settings, - updated_visibility + field: field_string, + show_in_overview: show_in_overview ) do {:ok, _updated_settings} -> 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 field_attributes = get_field_attributes(member_field) 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) # Create a manual form structure with string keys + # Note: immutable is not included as it's not editable for member fields form_data = %{ "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 || "", - "immutable" => field_attributes.immutable, "required" => field_attributes.required, "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 # 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 -> # Fallback for fields not in resource (shouldn't happen with Constants) %{ value_type: :string, description: nil, - immutable: field == :email, required: field in @required_fields } @@ -369,72 +323,11 @@ defmodule MvWeb.MemberFieldLive.FormComponent do %{ value_type: attribute.type, description: nil, - immutable: field == :email, required: not attribute.allow_nil? } 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 Ash.ErrorKind.message(error) end diff --git a/lib/mv_web/live/member_field_live/index_component.ex b/lib/mv_web/live/member_field_live/index_component.ex index 2d4f1dc..5204030 100644 --- a/lib/mv_web/live/member_field_live/index_component.ex +++ b/lib/mv_web/live/member_field_live/index_component.ex @@ -11,8 +11,10 @@ defmodule MvWeb.MemberFieldLive.IndexComponent do """ use MvWeb, :live_component + alias Ash.Resource.Info alias Mv.Membership - alias MvWeb.Translations.FieldTypes + alias Mv.Membership.Helpers.VisibilityConfig + alias MvWeb.Helpers.FieldTypeFormatter alias MvWeb.Translations.MemberFields @impl true @@ -180,11 +182,11 @@ defmodule MvWeb.MemberFieldLive.IndexComponent do visibility_config = settings.member_field_visibility || %{} # 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 -> 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, @@ -199,68 +201,15 @@ defmodule MvWeb.MemberFieldLive.IndexComponent do end defp format_value_type(field) when is_atom(field) do - case Ash.Resource.Info.attribute(Mv.Membership.Member, field) do - nil -> FieldTypes.label(:string) - attribute -> format_value_type(attribute.type) + case Info.attribute(Mv.Membership.Member, field) do + nil -> FieldTypeFormatter.format(:string) + attribute -> FieldTypeFormatter.format(attribute.type) 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 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 attribute -> not attribute.allow_nil? end diff --git a/lib/mv_web/live/member_live/index/field_visibility.ex b/lib/mv_web/live/member_live/index/field_visibility.ex index 627bbcf..9ba9267 100644 --- a/lib/mv_web/live/member_live/index/field_visibility.ex +++ b/lib/mv_web/live/member_live/index/field_visibility.ex @@ -20,6 +20,8 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do 3. Default (all fields visible) """ + alias Mv.Membership.Helpers.VisibilityConfig + @doc """ Gets all available fields for selection. @@ -177,7 +179,7 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do # 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, %{})) + VisibilityConfig.normalize(Map.get(settings, :member_field_visibility, %{})) member_fields = Mv.Constants.member_fields() @@ -201,27 +203,6 @@ defmodule MvWeb.MemberLive.Index.FieldVisibility do 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