Allow user-member association in edit/create views closes #168 #207
4 changed files with 271 additions and 8 deletions
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -121,6 +121,116 @@ defmodule MvWeb.UserLive.Form do
|
|||
<% end %>
|
||||
</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 %>
|
||||
|
moritz marked this conversation as resolved
|
||||
<!-- 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"
|
||||
|
moritz marked this conversation as resolved
carla
commented
if we also use it in members, maybe we can use it as dropdown component in core components to reuse it? if we also use it in members, maybe we can use it as dropdown component in core components to reuse it?
moritz
commented
If we really want to reuse it, we can extract it as component, but at the moment I wouldn't use it in members. If we really want to reuse it, we can extract it as component, but at the moment I wouldn't use it in members.
|
||||
aria-expanded={to_string(@show_member_dropdown)}
|
||||
autocomplete="off"
|
||||
/>
|
||||
|
moritz marked this conversation as resolved
carla
commented
Nice work! Nice work!
One thing: I cannot select a member via enter...For me it would be also fine to handle the accessibility of the dropdown in a seperate issue. Up to you :)
I am also a bit confused that we need to add JS for a simple dropdown actually....
|
||||
|
||||
<%= 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">
|
||||
{gettext("Save User")}
|
||||
</.button>
|
||||
|
|
@ -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}})
|
||||
|
||||
# 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
|
||||
|> 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}
|
||||
|
||||
{: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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -50,6 +50,13 @@
|
|||
{user.email}
|
||||
</: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}>
|
||||
<div class="sr-only">
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue
Nice that you considered user information :)