feat: add user-member linking UI with autocomplete (#168)
This commit is contained in:
parent
52a62bd679
commit
af193840e2
4 changed files with 271 additions and 8 deletions
|
|
@ -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))
|
||||||
|
|
|
||||||
|
|
@ -121,6 +121,116 @@ defmodule MvWeb.UserLive.Form do
|
||||||
<% 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")}
|
||||||
</.button>
|
</.button>
|
||||||
|
|
@ -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}})
|
||||||
|
|
||||||
|
# Unlink flag is set
|
||||||
|
socket.assigns[:unlink_member] ->
|
||||||
|
Mv.Accounts.update_user(user, %{member: nil})
|
||||||
|
|
||||||
|
# No changes to member relationship
|
||||||
|
true ->
|
||||||
|
{:ok, user}
|
||||||
|
end
|
||||||
|
|
||||||
|
case result do
|
||||||
|
{:ok, updated_user} ->
|
||||||
|
notify_parent({:saved, updated_user})
|
||||||
|
|
||||||
socket =
|
socket =
|
||||||
socket
|
socket
|
||||||
|> put_flash(:info, "User #{socket.assigns.form.source.type}d successfully")
|
|> put_flash(:info, "User #{socket.assigns.form.source.type}d successfully")
|
||||||
|> push_navigate(to: return_path(socket.assigns.return_to, user))
|
|> push_navigate(to: return_path(socket.assigns.return_to, updated_user))
|
||||||
|
|
||||||
{:noreply, socket}
|
{: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
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue