From 5fa0b48acc9e658f5357693beeb4209f0727bdbf Mon Sep 17 00:00:00 2001 From: carla Date: Tue, 16 Dec 2025 17:12:26 +0100 Subject: [PATCH] feat: adds form for member fields --- .../live/member_field_live/form_component.ex | 445 ++++++++++++++++++ .../live/member_field_live/index_component.ex | 252 +++++----- 2 files changed, 592 insertions(+), 105 deletions(-) create mode 100644 lib/mv_web/live/member_field_live/form_component.ex diff --git a/lib/mv_web/live/member_field_live/form_component.ex b/lib/mv_web/live/member_field_live/form_component.ex new file mode 100644 index 0000000..a9985cb --- /dev/null +++ b/lib/mv_web/live/member_field_live/form_component.ex @@ -0,0 +1,445 @@ +defmodule MvWeb.MemberFieldLive.FormComponent do + @moduledoc """ + 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 + - Restrict editing for email field (only show_in_overview can be changed) + - Real-time validation + - Updates Settings.member_field_visibility + + ## 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 + """ + use MvWeb, :live_component + + alias Mv.Membership + alias MvWeb.Translations.MemberFields + alias MvWeb.Translations.FieldTypes + + @required_fields [:first_name, :last_name, :email] + + @impl true + def render(assigns) do + assigns = + assigns + |> assign(:field_attributes, get_field_attributes(assigns.member_field)) + |> assign(:is_email_field?, assigns.member_field == :email) + |> assign(:field_label, MemberFields.label(assigns.member_field)) + + ~H""" +
+
+
+ <.button + type="button" + phx-click="cancel" + phx-target={@myself} + aria-label={gettext("Back to member field overview")} + > + <.icon name="hero-arrow-left" class="w-4 h-4" /> + +

+ {gettext("Edit Field: %{field}", field: @field_label)} +

+
+ + <.form + for={@form} + id={@id <> "-form"} + phx-change="validate" + phx-submit="save" + phx-target={@myself} + > +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ <.input + :if={not @is_email_field?} + field={@form[:description]} + type="text" + label={gettext("Description")} + disabled={@is_email_field?} + 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?} + /> + +
+
+ +
+
+ <.input + :if={not @is_email_field?} + field={@form[:required]} + type="checkbox" + label={gettext("Required")} + disabled={@is_email_field?} + readonly={@is_email_field?} + /> + + <.input + field={@form[:show_in_overview]} + type="checkbox" + label={gettext("Show in overview")} + /> + +
+ <.button type="button" phx-click="cancel" phx-target={@myself}> + {gettext("Cancel")} + + <.button phx-disable-with={gettext("Saving...")} variant="primary"> + {gettext("Save Field")} + +
+ +
+
+ """ + end + + @impl true + def update(assigns, socket) do + {:ok, + socket + |> assign(assigns) + |> assign_form()} + end + + @impl true + def handle_event("validate", %{"member_field" => member_field_params}, socket) do + # For member fields, we only validate show_in_overview + # Other fields are read-only or derived from the Member Resource + form = socket.assigns.form + + updated_params = + member_field_params + |> Map.put("show_in_overview", 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 = + form + |> Map.put(:value, updated_params) + |> Map.put(:errors, []) + + {:noreply, assign(socket, form: updated_form)} + end + + @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 || %{} + 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( + socket.assigns.settings, + updated_visibility + ) do + {:ok, _updated_settings} -> + socket.assigns.on_save.(socket.assigns.member_field, "update") + {:noreply, socket} + + {:error, error} -> + # Add error to form + form = + socket.assigns.form + |> Map.put(:errors, [ + %{field: :show_in_overview, message: format_error(error)} + ]) + + {:noreply, assign(socket, form: form)} + end + end + + @impl true + def handle_event("cancel", _params, socket) do + socket.assigns.on_cancel.() + {:noreply, socket} + end + + # Helper functions + + 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) + show_in_overview = Map.get(normalized_config, member_field, true) + + # Create a manual form structure with string keys + form_data = %{ + "name" => MemberFields.label(member_field), + "value_type" => format_value_type(field_attributes.value_type), + "description" => field_attributes.description || "", + "immutable" => field_attributes.immutable, + "required" => field_attributes.required, + "show_in_overview" => show_in_overview + } + + form = to_form(form_data, as: "member_field") + + assign(socket, form: form) + end + + 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 + 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 + } + + attribute -> + %{ + 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 + + defp format_error(error) do + inspect(error) + end +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 61c6578..7422f5a 100644 --- a/lib/mv_web/live/member_field_live/index_component.ex +++ b/lib/mv_web/live/member_field_live/index_component.ex @@ -6,12 +6,14 @@ defmodule MvWeb.MemberFieldLive.IndexComponent do - List all member fields from Mv.Constants.member_fields() - Display show_in_overview status as badge (Yes/No) - Display required status for required fields (first_name, last_name, email) - - Toggle show_in_overview flag for each field + - Edit member field properties (expandable form like custom fields) - Updates Settings.member_field_visibility """ use MvWeb, :live_component alias Mv.Membership + alias MvWeb.Translations.MemberFields + alias MvWeb.Translations.FieldTypes @required_fields [:first_name, :last_name, :email] @@ -24,123 +26,123 @@ defmodule MvWeb.MemberFieldLive.IndexComponent do ~H"""
- <.form_section title={gettext("Memberdata")}> -

- {gettext( - "These fields are neccessary for MILA to handle member identification and payment calculations in the future. Thus you cannot delete these fields but hide them in the member overview." - )} -

+

+ {gettext( + "These fields are neccessary for MILA to handle member identification and payment calculations in the future. Thus you cannot delete these fields but hide them in the member overview." + )} +

- <.table id="member_fields" rows={@member_fields}> - <:col :let={{_field_name, field_data}} label={gettext("Field Name")}> - {format_field_name(field_data.field)} - + <%!-- Show form when editing --%> +
+ <.live_component + module={MvWeb.MemberFieldLive.FormComponent} + id={@form_id} + member_field={@editing_member_field} + settings={@settings} + on_save={ + fn member_field, action -> + send(self(), {:member_field_saved, member_field, action}) + end + } + on_cancel={fn -> send_update(__MODULE__, id: @id, show_form: false) end} + /> +
- <:col - :let={{_field_name, field_data}} - label={gettext("Required")} - class="max-w-[9.375rem] text-center" + <%!-- Hide table when form is visible --%> + <.table + :if={!@show_form} + id="member_fields" + rows={@member_fields} + > + <:col :let={{_field_name, field_data}} label={gettext("Name")}> + {MemberFields.label(field_data.field)} + + + <:col :let={{_field_name, field_data}} label={gettext("Value Type")}> + {format_value_type(field_data.field)} + + + <:col :let={{_field_name, field_data}} label={gettext("Description")}> + {field_data.description || ""} + + + <:col + :let={{_field_name, field_data}} + label={gettext("Required")} + class="max-w-[9.375rem] text-center" + > + - - {gettext("Required")} - - - {gettext("Optional")} - - + {gettext("Required")} + + + {gettext("Optional")} + + - <:col - :let={{_field_name, field_data}} - label={gettext("Show in overview")} - class="max-w-[9.375rem] text-center" + <:col + :let={{_field_name, field_data}} + label={gettext("Show in overview")} + class="max-w-[9.375rem] text-center" + > + + {gettext("Yes")} + + + {gettext("No")} + + + + <:action :let={{_field_name, field_data}}> + <.link + phx-click="edit_member_field" + phx-value-field={Atom.to_string(field_data.field)} + phx-target={@myself} > - - {gettext("Yes")} - - - {gettext("No")} - - - - <:action :let={{_field_name, field_data}}> - - - - + {gettext("Edit")} + + +
""" end @impl true def update(assigns, socket) do + # If show_form is explicitly provided in assigns, reset editing state + socket = + if Map.has_key?(assigns, :show_form) and assigns.show_form == false do + socket + |> assign(:editing_member_field, nil) + |> assign(:form_id, "member-field-form-new") + else + socket + end + {:ok, socket |> assign(assigns) - |> assign_new(:settings, fn -> get_settings() end)} + |> assign_new(:settings, fn -> get_settings() end) + |> assign_new(:show_form, fn -> false end) + |> assign_new(:form_id, fn -> "member-field-form-new" end) + |> assign_new(:editing_member_field, fn -> nil end)} end @impl true - def handle_event("toggle_field_visibility", %{"field" => field_string}, socket) do + def handle_event("edit_member_field", %{"field" => field_string}, socket) do # Validate that the field is a valid member field before converting to atom valid_fields = Mv.Constants.member_fields() |> Enum.map(&Atom.to_string/1) if field_string in valid_fields do - {:ok, settings} = Membership.get_settings() + field_atom = String.to_existing_atom(field_string) - # Get current visibility config - current_visibility = settings.member_field_visibility || %{} - # 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) - - # Toggle the field visibility - current_value = Map.get(normalized_visibility, field_string, true) - new_value = !current_value - updated_visibility = Map.put(normalized_visibility, field_string, new_value) - - # Update settings - case Membership.update_member_field_visibility(settings, updated_visibility) do - {:ok, updated_settings} -> - # Send message to parent LiveView - send(self(), {:member_field_visibility_updated}) - - {:noreply, - socket - |> assign(:settings, updated_settings)} - - {:error, error} -> - # Send error message to parent LiveView for user feedback - send(self(), {:member_field_visibility_error, error}) - - {:noreply, socket} - end + {:noreply, + socket + |> assign(:show_form, true) + |> assign(:editing_member_field, field_atom) + |> assign(:form_id, "member-field-form-#{field_string}")} else {:noreply, socket} end @@ -169,9 +171,57 @@ defmodule MvWeb.MemberFieldLive.IndexComponent do Enum.map(member_fields, fn field -> show_in_overview = Map.get(normalized_config, field, true) + attribute = Ash.Resource.Info.attribute(Mv.Membership.Member, field) - {Atom.to_string(field), %{field: field, show_in_overview: show_in_overview}} + %{ + field: field, + show_in_overview: show_in_overview, + value_type: (attribute && attribute.type) || :string, + description: nil + } end) + |> Enum.map(fn field_data -> + {Atom.to_string(field_data.field), field_data} + end) + 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) + 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 @@ -197,12 +247,4 @@ defmodule MvWeb.MemberFieldLive.IndexComponent do defp required?(field) when field in @required_fields, do: true defp required?(_), do: false - - defp format_field_name(field) when is_atom(field) do - field - |> Atom.to_string() - |> String.replace("_", " ") - |> String.split() - |> Enum.map_join(" ", &String.capitalize/1) - end end