From e74e7cbd314345ad9481bb79430a4276f0272bc6 Mon Sep 17 00:00:00 2001 From: Moritz Date: Fri, 25 Jul 2025 01:54:24 +0200 Subject: [PATCH] WIP --- lib/accounts/user.ex | 15 + .../components/member_form_component.ex | 50 +++ lib/mv_web/live/user_live/form.ex | 311 ++++++++++++++---- 3 files changed, 307 insertions(+), 69 deletions(-) create mode 100644 lib/mv_web/components/member_form_component.ex diff --git a/lib/accounts/user.ex b/lib/accounts/user.ex index c4c0bbe..6604f91 100644 --- a/lib/accounts/user.ex +++ b/lib/accounts/user.ex @@ -66,10 +66,15 @@ defmodule Mv.Accounts.User do create :create_user do accept [:email, :member_id] + argument :member, :map + change manage_relationship(:member, type: :create) end update :update_user do + require_atomic? false accept [:email, :member_id] + argument :member, :map + change manage_relationship(:member, on_match: :update, on_no_match: :create) end # Admin action for direct password changes in admin panel @@ -118,6 +123,16 @@ defmodule Mv.Accounts.User do |> Ash.Changeset.change_attribute(:oidc_id, user_info["sub"] || user_info["id"]) end end + + create :register_with_password do + accept [:email] + argument :password, :string, allow_nil?: false, sensitive?: true + argument :password_confirmation, :string, allow_nil?: false, sensitive?: true + argument :member, :map + change AshAuthentication.Strategy.Password.HashPasswordChange + change AshAuthentication.GenerateTokenChange + change manage_relationship(:member, type: :create) + end end # Global validations - applied to all relevant actions diff --git a/lib/mv_web/components/member_form_component.ex b/lib/mv_web/components/member_form_component.ex new file mode 100644 index 0000000..15ac008 --- /dev/null +++ b/lib/mv_web/components/member_form_component.ex @@ -0,0 +1,50 @@ +defmodule MvWeb.MemberFormComponent do + use Phoenix.Component + use Gettext, backend: MvWeb.Gettext + import MvWeb.CoreComponents + + @doc """ + Reusable form for member data (without
-tag and buttons). + Expects: + - form: Phoenix.HTML.FormField for Member + - property_types: List of PropertyTypes + """ + attr :form, :any, required: true + attr :property_types, :list, required: true + def member_form(assigns) do + ~H""" + <.input field={@form[:first_name]} label={gettext("First Name")} required /> + <.input field={@form[:last_name]} label={gettext("Last Name")} required /> + <.input field={@form[:email]} label={gettext("Email")} required type="email" /> + <.input field={@form[:birth_date]} label={gettext("Birth Date")} type="date" /> + <.input field={@form[:paid]} label={gettext("Paid")} type="checkbox" /> + <.input field={@form[:phone_number]} label={gettext("Phone Number")} /> + <.input field={@form[:join_date]} label={gettext("Join Date")} type="date" /> + <.input field={@form[:exit_date]} label={gettext("Exit Date")} type="date" /> + <.input field={@form[:notes]} label={gettext("Notes")} /> + <.input field={@form[:city]} label={gettext("City")} /> + <.input field={@form[:street]} label={gettext("Street")} /> + <.input field={@form[:house_number]} label={gettext("House Number")} /> + <.input field={@form[:postal_code]} label={gettext("Postal Code")} /> + +

{gettext("Custom Properties")}

+ <.inputs_for :let={f_property} field={@form[:properties]}> + <% type = Enum.find(@property_types, &(&1.id == f_property[:property_type_id].value)) %> + <.inputs_for :let={value_form} field={f_property[:value]}> + <% input_type = + cond do + type && type.value_type == :boolean -> "checkbox" + type && type.value_type == :date -> :date + true -> "text" + end %> + <.input field={value_form[:value]} label={type && type.name} type={input_type} /> + + + + """ + end +end \ No newline at end of file diff --git a/lib/mv_web/live/user_live/form.ex b/lib/mv_web/live/user_live/form.ex index 3c349d4..45f145f 100644 --- a/lib/mv_web/live/user_live/form.ex +++ b/lib/mv_web/live/user_live/form.ex @@ -1,5 +1,6 @@ defmodule MvWeb.UserLive.Form do use MvWeb, :live_view + import MvWeb.MemberFormComponent, only: [member_form: 1] @impl true def render(assigns) do @@ -14,51 +15,82 @@ defmodule MvWeb.UserLive.Form do <.input field={@form[:email]} label={gettext("Email")} required type="email" /> -
-

{gettext("Member Assignment")}

+ <%= if !@user || !@user.member do %> +
+

{gettext("Member Assignment")}

- - - - - <%= if @member_assignment_mode == "assign_existing" do %> -
- <.input - field={@form[:member_id]} - label={gettext("Select Member")} - type="select" - options={@available_members} - prompt={gettext("Choose a member...")} +
- <% end %> -
+ + {gettext("Create new member with custom data")} + + + + + + <%= if @member_assignment_mode == "assign_existing" do %> +
+ <.input + field={@form[:member_id]} + label={gettext("Select Member")} + type="select" + options={@available_members} + prompt={gettext("Choose a member...")} + /> +
+ <% end %> + + <%= if @member_assignment_mode == "create_new" do %> +
+ <%= if @form[:member] do %> + <.inputs_for :let={f_member} field={@form[:member]}> + <.member_form form={f_member} property_types={@property_types} /> + + <% else %> +

{gettext("Member form will be available after saving the user.")}

+ <% end %> +
+ <% end %> +
+ <% else %> +
+

{gettext("Member Assignment")}

+

+ {gettext("Edit assigned member")}: {@user.member.first_name} {@user.member.last_name} ({@user.member.email}) +

+ <%= if @member_form do %> +
+ <.form for={@member_form} id="member-form" phx-change="validate_member" phx-submit="save_member"> + <.member_form form={@member_form} property_types={@property_types} /> + <.button type="submit" variant="primary" class="mt-4"> + {gettext("Save Member")} + + +
+ <% end %> +
+ <% end %>
@@ -150,7 +182,16 @@ defmodule MvWeb.UserLive.Form do user = case params["id"] do nil -> nil - id -> Ash.get!(Mv.Accounts.User, id, domain: Mv.Accounts) + id -> + user = Ash.get!(Mv.Accounts.User, id, domain: Mv.Accounts) + |> Ash.load!(:member) + + if user.member do + {:ok, member_with_properties} = Ash.load(user.member, properties: [:property_type]) + %{user | member: member_with_properties} + else + user + end end action = if is_nil(user), do: gettext("New"), else: gettext("Edit") @@ -167,6 +208,75 @@ defmodule MvWeb.UserLive.Form do {"#{member.first_name} #{member.last_name} (#{member.email})", member.id} end) + # Load PropertyTypes for MemberForm + {:ok, property_types} = Mv.Membership.list_property_types() + initial_properties = + Enum.map(property_types, fn pt -> + %{ + "property_type_id" => pt.id, + "value" => %{ + "type" => pt.value_type, + "value" => nil, + "_union_type" => Atom.to_string(pt.value_type) + } + } + end) + member_form = AshPhoenix.Form.for_create( + Mv.Membership.Member, + :create_member, + domain: Mv.Membership, + as: "member", + params: %{"properties" => initial_properties}, + forms: [auto?: true] + ) |> to_form() + + # Initialize member_form for existing users with members + member_form = if user && user.member do + {:ok, member_with_properties} = Ash.load(user.member, properties: [:property_type]) + + existing_properties = + member_with_properties.properties + |> Enum.map(& &1.property_type_id) + + is_missing_property = fn i -> + not Enum.member?(existing_properties, Map.get(i, "property_type_id")) + end + + params = %{ + "properties" => + Enum.map(member_with_properties.properties, fn prop -> + %{ + "property_type_id" => prop.property_type_id, + "value" => %{ + "_union_type" => Atom.to_string(prop.value.type), + "type" => prop.value.type, + "value" => prop.value.value + } + } + end) + } + + form = + AshPhoenix.Form.for_update( + member_with_properties, + :update_member, + domain: Mv.Membership, + as: "member", + params: params, + forms: [auto?: true] + ) + + missing_properties = Enum.filter(initial_properties, is_missing_property) + + Enum.reduce( + missing_properties, + form, + &AshPhoenix.Form.add_form(&2, [:properties], params: &1) + ) |> to_form() + else + member_form + end + {:ok, socket |> assign(:return_to, return_to(params["return_to"])) @@ -175,6 +285,9 @@ defmodule MvWeb.UserLive.Form do |> assign(:show_password_fields, false) |> assign(:member_assignment_mode, "create_new") |> assign(:available_members, available_member_options) + |> assign(:property_types, property_types) + |> assign(:initial_properties, initial_properties) + |> assign(:member_form, member_form) |> assign_form()} end @@ -202,22 +315,46 @@ defmodule MvWeb.UserLive.Form do {:noreply, socket} end + def handle_event("validate", %{"user" => user_params, "member" => member_params}, socket) do + member_form = AshPhoenix.Form.validate(socket.assigns.member_form.source, member_params) |> to_form() + user_form = AshPhoenix.Form.validate(socket.assigns.form.source, user_params) + {:noreply, assign(socket, form: user_form, member_form: member_form)} + end + def handle_event("validate", %{"user" => user_params}, socket) do {:noreply, assign(socket, form: AshPhoenix.Form.validate(socket.assigns.form, user_params))} end - def handle_event("save", %{"user" => user_params}, socket) do + def handle_event("validate_member", %{"member" => member_params}, socket) do + member_form = AshPhoenix.Form.validate(socket.assigns.member_form.source, member_params) |> to_form() + {:noreply, assign(socket, member_form: member_form)} + end + + def handle_event("save_member", %{"member" => member_params}, socket) do + case AshPhoenix.Form.submit(socket.assigns.member_form.source, params: member_params) do + {:ok, member} -> + # Reload the user with updated member + user = socket.assigns.user |> Ash.load!(:member) + socket = + socket + |> assign(user: user) + |> put_flash(:info, "Member updated successfully") + {:noreply, socket} + {:error, member_form} -> + {:noreply, assign(socket, member_form: to_form(member_form))} + end + end + + def handle_event("save", params, socket) do + user_params = params["user"] || %{} case AshPhoenix.Form.submit(socket.assigns.form, params: user_params) do {:ok, user} -> notify_parent({:saved, user}) - socket = socket |> put_flash(:info, "User #{socket.assigns.form.source.type}d successfully") |> push_navigate(to: return_path(socket.assigns.return_to, user)) - {:noreply, socket} - {:error, form} -> {:noreply, assign(socket, form: form)} end @@ -225,36 +362,72 @@ defmodule MvWeb.UserLive.Form do defp notify_parent(msg), do: send(self(), {__MODULE__, msg}) - defp assign_form(%{assigns: %{user: user, show_password_fields: show_password_fields}} = socket) do + defp assign_form(%{assigns: %{user: user, show_password_fields: show_password_fields, member_assignment_mode: member_assignment_mode, property_types: property_types}} = socket) do form = if user do # For existing users, use admin password action if password fields are shown action = if show_password_fields, do: :admin_set_password, else: :update_user - AshPhoenix.Form.for_update(user, action, - as: "user", - actor: socket.assigns.current_user, - domain: Mv.Accounts - ) + if member_assignment_mode == "create_new" do + initial_properties = + Enum.map(property_types, fn pt -> + %{ + "property_type_id" => pt.id, + "value" => %{ + "type" => pt.value_type, + "value" => nil, + "_union_type" => Atom.to_string(pt.value_type) + } + } + end) + params = %{"member" => %{"properties" => initial_properties}} + AshPhoenix.Form.for_update(user, action, + as: "user", + actor: socket.assigns.current_user, + domain: Mv.Accounts, + params: params, + forms: [auto?: true] + ) + else + AshPhoenix.Form.for_update(user, action, + as: "user", + actor: socket.assigns.current_user, + domain: Mv.Accounts + ) + end else # For new users, use password registration if password fields are shown action = if show_password_fields, do: :register_with_password, else: :create_user - # Only include member_id if assign_existing mode is selected AND not using password action - accept = - if socket.assigns.member_assignment_mode == "assign_existing" and - not show_password_fields do - [:email, :member_id] - else - [:email] - end - - AshPhoenix.Form.for_create(Mv.Accounts.User, action, - as: "user", - actor: socket.assigns.current_user, - domain: Mv.Accounts, - accept: accept - ) + if member_assignment_mode == "create_new" do + initial_properties = + Enum.map(property_types, fn pt -> + %{ + "property_type_id" => pt.id, + "value" => %{ + "type" => pt.value_type, + "value" => nil, + "_union_type" => Atom.to_string(pt.value_type) + } + } + end) + params = %{"member" => %{"properties" => initial_properties}} + AshPhoenix.Form.for_create(Mv.Accounts.User, action, + as: "user", + actor: socket.assigns.current_user, + domain: Mv.Accounts, + accept: [:email, :password, :password_confirmation], + params: params, + forms: [auto?: true] + ) + else + AshPhoenix.Form.for_create(Mv.Accounts.User, action, + as: "user", + actor: socket.assigns.current_user, + domain: Mv.Accounts, + accept: if(member_assignment_mode == "assign_existing", do: [:email, :member_id], else: [:email]) + ) + end end assign(socket, form: to_form(form))