From af193840e2fe47b2e067780ca578f6f27156c62e Mon Sep 17 00:00:00 2001 From: Moritz Date: Thu, 20 Nov 2025 15:55:50 +0100 Subject: [PATCH] feat: add user-member linking UI with autocomplete (#168) --- assets/js/app.js | 10 + lib/mv_web/live/user_live/form.ex | 260 +++++++++++++++++++++- lib/mv_web/live/user_live/index.ex | 2 +- lib/mv_web/live/user_live/index.html.heex | 7 + 4 files changed, 271 insertions(+), 8 deletions(-) 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}

+
+ +
+
+ <% 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}>