feat: add user-member linking UI with autocomplete (#168)

This commit is contained in:
Moritz 2025-11-20 15:55:50 +01:00
parent e30778d286
commit 56cfa01711
4 changed files with 271 additions and 8 deletions

View file

@ -23,11 +23,21 @@ import {LiveSocket} from "phoenix_live_view"
import topbar from "../vendor/topbar" import topbar from "../vendor/topbar"
let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content") let csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute("content")
let liveSocket = new LiveSocket("/live", Socket, { let liveSocket = new LiveSocket("/live", Socket, {
longPollFallbackMs: 2500, longPollFallbackMs: 2500,
params: {_csrf_token: csrfToken} 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 // Show progress bar on live navigation and form submits
topbar.config({barColors: {0: "#29d"}, shadowColor: "rgba(0, 0, 0, .3)"}) topbar.config({barColors: {0: "#29d"}, shadowColor: "rgba(0, 0, 0, .3)"})
window.addEventListener("phx:page-loading-start", _info => topbar.show(300)) window.addEventListener("phx:page-loading-start", _info => topbar.show(300))

View file

@ -120,6 +120,116 @@ defmodule MvWeb.UserLive.Form do
<% end %> <% end %>
<% end %> <% end %>
</div> </div>
<!-- Member Linking Section -->
<div class="mt-6">
<h2 class="text-base font-semibold mb-3">{gettext("Linked Member")}</h2>
<%= if @user && @user.member && !@unlink_member do %>
<!-- Show linked member with unlink button -->
<div class="p-4 bg-green-50 border border-green-200 rounded-lg">
<div class="flex items-center justify-between">
<div>
<p class="font-medium text-green-900">
{@user.member.first_name} {@user.member.last_name}
</p>
<p class="text-sm text-green-700">{@user.member.email}</p>
</div>
<button
type="button"
phx-click="unlink_member"
class="btn btn-sm btn-error"
>
{gettext("Unlink Member")}
</button>
</div>
</div>
<% else %>
<%= if @unlink_member do %>
<!-- Show unlink pending message -->
<div class="p-4 bg-yellow-50 border border-yellow-200 rounded-lg">
<p class="text-sm text-yellow-800">
<strong>{gettext("Unlinking scheduled")}:</strong> {gettext(
"Member will be unlinked when you save. Cannot select new member until saved."
)}
</p>
</div>
<% end %>
<!-- Show member search/selection for unlinked users -->
<div class="space-y-3">
<div class="relative">
<input
type="text"
id="member-search-input"
role="combobox"
phx-focus="show_member_dropdown"
phx-change="search_members"
phx-debounce="300"
value={@member_search_query}
placeholder={gettext("Search for a member to link...")}
class="w-full input"
name="member_search"
disabled={@unlink_member}
aria-label={gettext("Search for member to link")}
aria-describedby={if @selected_member_name, do: "member-selected", else: nil}
aria-autocomplete="list"
aria-controls="member-dropdown"
aria-expanded={to_string(@show_member_dropdown)}
autocomplete="off"
/>
<%= if length(@available_members) > 0 do %>
<div
id="member-dropdown"
role="listbox"
aria-label={gettext("Available members")}
class={"absolute z-10 w-full mt-1 bg-base-100 border border-base-300 rounded-lg shadow-lg max-h-60 overflow-auto #{if !@show_member_dropdown, do: "hidden"}"}
phx-click-away="hide_member_dropdown"
>
<%= for member <- @available_members do %>
<div
role="option"
tabindex="0"
aria-selected="false"
phx-click="select_member"
phx-value-id={member.id}
data-member-id={member.id}
class="px-4 py-3 hover:bg-base-200 cursor-pointer border-b border-base-300 last:border-b-0"
>
<p class="font-medium">{member.first_name} {member.last_name}</p>
<p class="text-sm text-base-content/70">{member.email}</p>
</div>
<% end %>
</div>
<% end %>
</div>
<%= if @user && @user.email && @available_members != [] && Enum.all?(@available_members, &(&1.email == to_string(@user.email))) do %>
<div class="p-3 bg-yellow-50 border border-yellow-200 rounded">
<p class="text-sm text-yellow-800">
<strong>{gettext("Note")}:</strong> {gettext(
"A member with this email already exists. To link with a different member, please change one of the email addresses first."
)}
</p>
</div>
<% end %>
<%= if @selected_member_id && @selected_member_name do %>
<div
id="member-selected"
class="mt-2 p-3 bg-blue-50 border border-blue-200 rounded-lg"
>
<p class="text-sm text-blue-800">
<strong>{gettext("Selected")}:</strong> {@selected_member_name}
</p>
<p class="text-xs text-blue-600 mt-1">
{gettext("Save to confirm linking.")}
</p>
</div>
<% end %>
</div>
<% end %>
</div>
<.button phx-disable-with={gettext("Saving...")} variant="primary"> <.button phx-disable-with={gettext("Saving...")} variant="primary">
{gettext("Save User")} {gettext("Save User")}
@ -135,7 +245,7 @@ defmodule MvWeb.UserLive.Form do
user = user =
case params["id"] do case params["id"] do
nil -> nil 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 end
action = if is_nil(user), do: gettext("New"), else: gettext("Edit") action = if is_nil(user), do: gettext("New"), else: gettext("Edit")
@ -147,6 +257,13 @@ defmodule MvWeb.UserLive.Form do
|> assign(user: user) |> assign(user: user)
|> assign(:page_title, page_title) |> assign(:page_title, page_title)
|> assign(:show_password_fields, false) |> 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()} |> assign_form()}
end end
@ -170,22 +287,102 @@ defmodule MvWeb.UserLive.Form do
end end
def handle_event("save", %{"user" => user_params}, socket) do 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 case AshPhoenix.Form.submit(socket.assigns.form, params: user_params) do
{:ok, user} -> {: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 = # Unlink flag is set
socket socket.assigns[:unlink_member] ->
|> put_flash(:info, "User #{socket.assigns.form.source.type}d successfully") Mv.Accounts.update_user(user, %{member: nil})
|> push_navigate(to: return_path(socket.assigns.return_to, user))
{: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} -> {:error, form} ->
{:noreply, assign(socket, form: form)} {:noreply, assign(socket, form: form)}
end end
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 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}} = socket) do
@ -209,4 +406,53 @@ defmodule MvWeb.UserLive.Form do
defp return_path("index", _user), do: ~p"/users" defp return_path("index", _user), do: ~p"/users"
defp return_path("show", user), do: ~p"/users/#{user.id}" 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 end

View file

@ -25,7 +25,7 @@ defmodule MvWeb.UserLive.Index do
@impl true @impl true
def mount(_params, _session, socket) do 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) sorted = Enum.sort_by(users, & &1.email)
{:ok, {:ok,

View file

@ -50,6 +50,13 @@
{user.email} {user.email}
</:col> </:col>
<:col :let={user} label={gettext("OIDC ID")}>{user.oidc_id}</:col> <:col :let={user} label={gettext("OIDC ID")}>{user.oidc_id}</:col>
<:col :let={user} label={gettext("Linked Member")}>
<%= if user.member do %>
{user.member.first_name} {user.member.last_name}
<% else %>
<span class="text-base-content/50">{gettext("No member linked")}</span>
<% end %>
</:col>
<:action :let={user}> <:action :let={user}>
<div class="sr-only"> <div class="sr-only">