diff --git a/assets/js/app.js b/assets/js/app.js
index d5e278a..9b95296 100644
--- a/assets/js/app.js
+++ b/assets/js/app.js
@@ -23,11 +23,21 @@ import {LiveSocket} from "phoenix_live_view"
import topbar from "../vendor/topbar"
let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
+
let liveSocket = new LiveSocket("/live", Socket, {
longPollFallbackMs: 2500,
params: {_csrf_token: csrfToken}
})
+// Listen for custom events from LiveView
+window.addEventListener("phx:set-input-value", (e) => {
+ const {id, value} = e.detail
+ const input = document.getElementById(id)
+ if (input) {
+ input.value = value
+ }
+})
+
// Show progress bar on live navigation and form submits
topbar.config({barColors: {0: "#29d"}, shadowColor: "rgba(0, 0, 0, .3)"})
window.addEventListener("phx:page-loading-start", _info => topbar.show(300))
diff --git a/lib/mv_web/live/user_live/form.ex b/lib/mv_web/live/user_live/form.ex
index cf7b687..82df862 100644
--- a/lib/mv_web/live/user_live/form.ex
+++ b/lib/mv_web/live/user_live/form.ex
@@ -120,6 +120,116 @@ defmodule MvWeb.UserLive.Form do
<% end %>
<% end %>
+
+
+
+
{gettext("Linked Member")}
+
+ <%= if @user && @user.member && !@unlink_member do %>
+
+
+
+
+
+ {@user.member.first_name} {@user.member.last_name}
+
+
{@user.member.email}
+
+
+ {gettext("Unlink Member")}
+
+
+
+ <% else %>
+ <%= if @unlink_member do %>
+
+
+
+ {gettext("Unlinking scheduled")}: {gettext(
+ "Member will be unlinked when you save. Cannot select new member until saved."
+ )}
+
+
+ <% end %>
+
+
+
+
+
+ <%= if length(@available_members) > 0 do %>
+
+ <%= for member <- @available_members do %>
+
+
{member.first_name} {member.last_name}
+
{member.email}
+
+ <% end %>
+
+ <% end %>
+
+
+ <%= if @user && @user.email && @available_members != [] && Enum.all?(@available_members, &(&1.email == to_string(@user.email))) do %>
+
+
+ {gettext("Note")}: {gettext(
+ "A member with this email already exists. To link with a different member, please change one of the email addresses first."
+ )}
+
+
+ <% end %>
+
+ <%= if @selected_member_id && @selected_member_name do %>
+
+
+ {gettext("Selected")}: {@selected_member_name}
+
+
+ {gettext("Save to confirm linking.")}
+
+
+ <% end %>
+
+ <% end %>
+
<.button phx-disable-with={gettext("Saving...")} variant="primary">
{gettext("Save User")}
@@ -135,7 +245,7 @@ defmodule MvWeb.UserLive.Form do
user =
case params["id"] do
nil -> nil
- id -> Ash.get!(Mv.Accounts.User, id, domain: Mv.Accounts)
+ id -> Ash.get!(Mv.Accounts.User, id, domain: Mv.Accounts, load: [:member])
end
action = if is_nil(user), do: gettext("New"), else: gettext("Edit")
@@ -147,6 +257,13 @@ defmodule MvWeb.UserLive.Form do
|> assign(user: user)
|> assign(:page_title, page_title)
|> assign(:show_password_fields, false)
+ |> assign(:member_search_query, "")
+ |> assign(:available_members, [])
+ |> assign(:show_member_dropdown, false)
+ |> assign(:selected_member_id, nil)
+ |> assign(:selected_member_name, nil)
+ |> assign(:unlink_member, false)
+ |> load_initial_members()
|> assign_form()}
end
@@ -170,22 +287,102 @@ defmodule MvWeb.UserLive.Form do
end
def handle_event("save", %{"user" => user_params}, socket) do
+ # First save the user without member changes
case AshPhoenix.Form.submit(socket.assigns.form, params: user_params) do
{:ok, user} ->
- notify_parent({:saved, user})
+ # Then handle member linking/unlinking as a separate step
+ result =
+ cond do
+ # Selected member ID takes precedence (new link)
+ socket.assigns.selected_member_id ->
+ Mv.Accounts.update_user(user, %{member: %{id: socket.assigns.selected_member_id}})
- socket =
- socket
- |> put_flash(:info, "User #{socket.assigns.form.source.type}d successfully")
- |> push_navigate(to: return_path(socket.assigns.return_to, user))
+ # Unlink flag is set
+ socket.assigns[:unlink_member] ->
+ Mv.Accounts.update_user(user, %{member: nil})
- {:noreply, socket}
+ # No changes to member relationship
+ true ->
+ {:ok, user}
+ end
+
+ case result do
+ {:ok, updated_user} ->
+ notify_parent({:saved, updated_user})
+
+ socket =
+ socket
+ |> put_flash(:info, "User #{socket.assigns.form.source.type}d successfully")
+ |> push_navigate(to: return_path(socket.assigns.return_to, updated_user))
+
+ {:noreply, socket}
+
+ {:error, error} ->
+ # Show error from member linking/unlinking
+ {:noreply,
+ put_flash(socket, :error, "Failed to update member relationship: #{inspect(error)}")}
+ end
{:error, form} ->
{:noreply, assign(socket, form: form)}
end
end
+ def handle_event("show_member_dropdown", _params, socket) do
+ {:noreply, assign(socket, show_member_dropdown: true)}
+ end
+
+ def handle_event("hide_member_dropdown", _params, socket) do
+ {:noreply, assign(socket, show_member_dropdown: false)}
+ end
+
+ def handle_event("search_members", %{"member_search" => query}, socket) do
+ socket =
+ socket
+ |> assign(:member_search_query, query)
+ |> load_available_members(query)
+ |> assign(:show_member_dropdown, true)
+
+ {:noreply, socket}
+ end
+
+ def handle_event("select_member", %{"id" => member_id}, socket) do
+ # Find the selected member to get their name
+ selected_member = Enum.find(socket.assigns.available_members, &(&1.id == member_id))
+
+ member_name =
+ if selected_member,
+ do: "#{selected_member.first_name} #{selected_member.last_name}",
+ else: ""
+
+ # Store the selected member ID and name in socket state and clear unlink flag
+ socket =
+ socket
+ |> assign(:selected_member_id, member_id)
+ |> assign(:selected_member_name, member_name)
+ |> assign(:unlink_member, false)
+ |> assign(:show_member_dropdown, false)
+ |> assign(:member_search_query, member_name)
+ |> push_event("set-input-value", %{id: "member-search-input", value: member_name})
+
+ {:noreply, socket}
+ end
+
+ def handle_event("unlink_member", _params, socket) do
+ # Set flag to unlink member on save
+ # Clear all member selection state and keep dropdown hidden
+ socket =
+ socket
+ |> assign(:unlink_member, true)
+ |> assign(:selected_member_id, nil)
+ |> assign(:selected_member_name, nil)
+ |> assign(:member_search_query, "")
+ |> assign(:show_member_dropdown, false)
+ |> load_initial_members()
+
+ {:noreply, socket}
+ end
+
defp notify_parent(msg), do: send(self(), {__MODULE__, msg})
defp assign_form(%{assigns: %{user: user, show_password_fields: show_password_fields}} = socket) do
@@ -209,4 +406,53 @@ defmodule MvWeb.UserLive.Form do
defp return_path("index", _user), do: ~p"/users"
defp return_path("show", user), do: ~p"/users/#{user.id}"
+
+ # Load initial members when the form is loaded or member is unlinked
+ defp load_initial_members(socket) do
+ user = socket.assigns.user
+ user_email = if user, do: user.email, else: nil
+
+ members = load_members_for_linking(user_email, "")
+
+ # Dropdown should ALWAYS be hidden initially
+ # It will only show when user focuses the input field (show_member_dropdown event)
+ socket
+ |> assign(available_members: members)
+ |> assign(show_member_dropdown: false)
+ end
+
+ # Load members based on search query
+ defp load_available_members(socket, query) do
+ user = socket.assigns.user
+ user_email = if user, do: user.email, else: nil
+
+ members = load_members_for_linking(user_email, query)
+ assign(socket, available_members: members)
+ end
+
+ # Query available members using the Ash action
+ defp load_members_for_linking(user_email, search_query) do
+ user_email_str = if user_email, do: to_string(user_email), else: nil
+ search_query_str = if search_query && search_query != "", do: search_query, else: nil
+
+ query =
+ Mv.Membership.Member
+ |> Ash.Query.for_read(:available_for_linking, %{
+ user_email: user_email_str,
+ search_query: search_query_str
+ })
+
+ case Ash.read(query, domain: Mv.Membership) do
+ {:ok, members} ->
+ # Apply email match filter if user_email is provided
+ if user_email_str do
+ Mv.Membership.Member.filter_by_email_match(members, user_email_str)
+ else
+ members
+ end
+
+ {:error, _} ->
+ []
+ end
+ end
end
diff --git a/lib/mv_web/live/user_live/index.ex b/lib/mv_web/live/user_live/index.ex
index 8803237..0c1d7be 100644
--- a/lib/mv_web/live/user_live/index.ex
+++ b/lib/mv_web/live/user_live/index.ex
@@ -25,7 +25,7 @@ defmodule MvWeb.UserLive.Index do
@impl true
def mount(_params, _session, socket) do
- users = Ash.read!(Mv.Accounts.User, domain: Mv.Accounts)
+ users = Ash.read!(Mv.Accounts.User, domain: Mv.Accounts, load: [:member])
sorted = Enum.sort_by(users, & &1.email)
{:ok,
diff --git a/lib/mv_web/live/user_live/index.html.heex b/lib/mv_web/live/user_live/index.html.heex
index 66e3b9e..3582046 100644
--- a/lib/mv_web/live/user_live/index.html.heex
+++ b/lib/mv_web/live/user_live/index.html.heex
@@ -50,6 +50,13 @@
{user.email}
<:col :let={user} label={gettext("OIDC ID")}>{user.oidc_id}
+ <:col :let={user} label={gettext("Linked Member")}>
+ <%= if user.member do %>
+ {user.member.first_name} {user.member.last_name}
+ <% else %>
+ {gettext("No member linked")}
+ <% end %>
+
<:action :let={user}>