WIP: feature/119_user_as_member #124

Closed
moritz wants to merge 2 commits from feature/119_user_as_member into main
3 changed files with 307 additions and 69 deletions
Showing only changes of commit e74e7cbd31 - Show all commits

View file

@ -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)

Does this mean that users will always have an associated member? I don't really remember what our plan was in this regard.

Also, I'm not sure how this interacts with the new MemberCreationNotifier that also creates members for users. Do we need both ways to create new members?

Does this mean that users will always have an associated member? I don't really remember what our plan was in this regard. Also, I'm not sure how this interacts with the new `MemberCreationNotifier` that also creates members for users. Do we need both ways to create new members?
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

View file

@ -0,0 +1,50 @@
defmodule MvWeb.MemberFormComponent do

This is pretty similar to the MemberLive.Form liveview, can we extract some common code here?

This is pretty similar to the `MemberLive.Form` liveview, can we extract some common code here?
use Phoenix.Component
use Gettext, backend: MvWeb.Gettext
import MvWeb.CoreComponents
@doc """
Reusable form for member data (without <form>-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")} />
<h3 class="mt-8 mb-2 text-lg font-semibold">{gettext("Custom Properties")}</h3>
<.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} />
</.inputs_for>
<input
type="hidden"
name={f_property[:property_type_id].name}
value={f_property[:property_type_id].value}
/>
</.inputs_for>
"""
end
end

View file

@ -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" />
<!-- Member Assignment Section -->
<div class="mt-6 space-y-4">
<h3 class="text-lg font-medium">{gettext("Member Assignment")}</h3>
<%= if !@user || !@user.member do %>
<div class="mt-6 space-y-4">
<h3 class="text-lg font-medium">{gettext("Member Assignment")}</h3>
<label class="flex items-center space-x-2">
<input
type="radio"
name="member_assignment_mode"
value="create_new"
phx-click="set_member_mode"
phx-value-mode="create_new"
checked={@member_assignment_mode == "create_new"}
class="radio radio-sm"
/>
<span class="text-sm">
{gettext("Create new member automatically")}
</span>
</label>
<label class="flex items-center space-x-2">
<input
type="radio"
name="member_assignment_mode"
value="assign_existing"
phx-click="set_member_mode"
phx-value-mode="assign_existing"
checked={@member_assignment_mode == "assign_existing"}
class="radio radio-sm"
/>
<span class="text-sm">
{gettext("Assign to existing member")}
</span>
</label>
<%= if @member_assignment_mode == "assign_existing" do %>
<div class="ml-6 mt-2">
<.input
field={@form[:member_id]}
label={gettext("Select Member")}
type="select"
options={@available_members}
prompt={gettext("Choose a member...")}
<label class="flex items-center space-x-2">
<input
type="radio"
name="member_assignment_mode"
value="create_new"
phx-click="set_member_mode"
phx-value-mode="create_new"
checked={@member_assignment_mode == "create_new"}
class="radio radio-sm"
/>
</div>
<% end %>
</div>
<span class="text-sm">
{gettext("Create new member with custom data")}
</span>
</label>
<label class="flex items-center space-x-2">
<input
type="radio"
name="member_assignment_mode"
value="assign_existing"
phx-click="set_member_mode"
phx-value-mode="assign_existing"
checked={@member_assignment_mode == "assign_existing"}
class="radio radio-sm"
/>
<span class="text-sm">
{gettext("Assign to existing member")}
</span>
</label>
<%= if @member_assignment_mode == "assign_existing" do %>
<div class="ml-6 mt-2">
<.input
field={@form[:member_id]}
label={gettext("Select Member")}
type="select"
options={@available_members}
prompt={gettext("Choose a member...")}
/>
</div>
<% end %>
<%= if @member_assignment_mode == "create_new" do %>
<div class="ml-6 mt-4 p-4 bg-gray-50 rounded-lg">
<%= if @form[:member] do %>
<.inputs_for :let={f_member} field={@form[:member]}>
<.member_form form={f_member} property_types={@property_types} />
</.inputs_for>
<% else %>
<p class="text-sm text-gray-600">{gettext("Member form will be available after saving the user.")}</p>
<% end %>
</div>
<% end %>
</div>
<% else %>
<div class="mt-6 space-y-4">
<h3 class="text-lg font-medium">{gettext("Member Assignment")}</h3>
<p class="text-sm text-gray-600">
{gettext("Edit assigned member")}: {@user.member.first_name} {@user.member.last_name} ({@user.member.email})
</p>
<%= if @member_form do %>
<div class="ml-6 mt-4 p-4 bg-gray-50 rounded-lg">
<.form for={@member_form} id="member-form" phx-change="validate_member" phx-submit="save_member">
<.member_form form={@member_form} property_types={@property_types} />

It only worked for me, if you use here inputs_for as above. Only one submit button for the form is working. So save_member would never be triggered. You can remove the "Save member" button here and adjust the "Save" event to handle both - saving user and member. That worked for me.

It only worked for me, if you use here `inputs_for` as above. Only one submit button for the form is working. So save_member would never be triggered. You can remove the "Save member" button here and adjust the "Save" event to handle both - saving user and member. That worked for me.
<.button type="submit" variant="primary" class="mt-4">
{gettext("Save Member")}
</.button>
</.form>
</div>
<% end %>
</div>
<% end %>
<!-- Password Section -->
<div class="mt-6">
@ -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()

We could convert the MemberForm into a LiveComponent. This way, it gets its own mount function which we can move some of this logic into, making the user form page a bit smaller and easier to read.

We could convert the `MemberForm` into a LiveComponent. This way, it gets its own `mount` function which we can move some of this logic into, making the user form page a bit smaller and easier to read.
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)

I had to change this to:
user_form = AshPhoenix.Form.validate(socket.assigns.form.source, user_params) |> to_form()
to make it work

I had to change this to: `user_form = AshPhoenix.Form.validate(socket.assigns.form.source, user_params) |> to_form()` to make it work
{: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))