refactor: when adding group members, search in-memory on typing

This commit is contained in:
Simon 2026-02-20 15:56:12 +01:00
parent ec814a8c94
commit 83b104ecf3
Signed by: simon
GPG key ID: 40E7A58C4AA1EDB2
5 changed files with 93 additions and 85 deletions

View file

@ -1264,6 +1264,8 @@ end
### 3.12 Internationalization: Gettext ### 3.12 Internationalization: Gettext
**German (de):** Use informal address (“duzen”). All user-facing German text should address the user as “du” (e.g. “Bitte versuche es erneut”, “Deine Einstellungen”), not “Sie”.
**Define Translations:** **Define Translations:**
```elixir ```elixir

View file

@ -17,10 +17,12 @@ defmodule MvWeb.GroupLive.Show do
require Logger require Logger
import Ash.Expr
import MvWeb.LiveHelpers, only: [current_actor: 1] import MvWeb.LiveHelpers, only: [current_actor: 1]
import MvWeb.Authorization import MvWeb.Authorization
alias Mv.Membership alias Mv.Membership
alias MvWeb.Helpers.MemberHelpers, as: MemberHelpers
@impl true @impl true
def mount(_params, _session, socket) do def mount(_params, _session, socket) do
@ -29,6 +31,7 @@ defmodule MvWeb.GroupLive.Show do
|> assign(:show_add_member_input, false) |> assign(:show_add_member_input, false)
|> assign(:member_search_query, "") |> assign(:member_search_query, "")
|> assign(:available_members, []) |> assign(:available_members, [])
|> assign(:add_member_candidates, [])
|> assign(:selected_member_ids, []) |> assign(:selected_member_ids, [])
|> assign(:selected_members, []) |> assign(:selected_members, [])
|> assign(:show_member_dropdown, false) |> assign(:show_member_dropdown, false)
@ -94,12 +97,12 @@ defmodule MvWeb.GroupLive.Show do
</h1> </h1>
<div class="flex gap-2"> <div class="flex gap-2">
<%= if can?(@current_user, :update, Mv.Membership.Group) do %> <%= if can?(@current_user, :update, @group) do %>
<.button variant="primary" navigate={~p"/groups/#{@group.slug}/edit"}> <.button variant="primary" navigate={~p"/groups/#{@group.slug}/edit"}>
{gettext("Edit")} {gettext("Edit")}
</.button> </.button>
<% end %> <% end %>
<%= if can?(@current_user, :destroy, Mv.Membership.Group) do %> <%= if can?(@current_user, :destroy, @group) do %>
<.button class="btn-error" phx-click="open_delete_modal"> <.button class="btn-error" phx-click="open_delete_modal">
{gettext("Delete")} {gettext("Delete")}
</.button> </.button>
@ -132,7 +135,7 @@ defmodule MvWeb.GroupLive.Show do
)} )}
</p> </p>
<%= if can?(@current_user, :update, Mv.Membership.Group) do %> <%= if can?(@current_user, :update, @group) do %>
<div class="mb-4"> <div class="mb-4">
<%= if assigns[:show_add_member_input] do %> <%= if assigns[:show_add_member_input] do %>
<div class="join w-full"> <div class="join w-full">
@ -263,7 +266,7 @@ defmodule MvWeb.GroupLive.Show do
<tr> <tr>
<th>{gettext("Name")}</th> <th>{gettext("Name")}</th>
<th>{gettext("Email")}</th> <th>{gettext("Email")}</th>
<%= if can?(@current_user, :update, Mv.Membership.Group) do %> <%= if can?(@current_user, :update, @group) do %>
<th class="w-0">{gettext("Actions")}</th> <th class="w-0">{gettext("Actions")}</th>
<% end %> <% end %>
</tr> </tr>
@ -291,7 +294,7 @@ defmodule MvWeb.GroupLive.Show do
<span class="text-base-content/50 italic"></span> <span class="text-base-content/50 italic"></span>
<% end %> <% end %>
</td> </td>
<%= if can?(@current_user, :update, Mv.Membership.Group) do %> <%= if can?(@current_user, :update, @group) do %>
<td> <td>
<button <button
type="button" type="button"
@ -431,24 +434,31 @@ defmodule MvWeb.GroupLive.Show do
# Add Member Events # Add Member Events
@impl true @impl true
def handle_event("show_add_member_input", _params, socket) do def handle_event("show_add_member_input", _params, socket) do
# Use existing @group from assigns; no DB read on focus. Reload only on commit (add/remove). # Load candidate members once (single DB read). Search/focus then filter in memory (R2).
{:noreply, socket =
socket socket
|> assign(:show_add_member_input, true) |> assign(:show_add_member_input, true)
|> assign(:member_search_query, "") |> assign(:member_search_query, "")
|> assign(:available_members, []) |> assign(:selected_member_ids, [])
|> assign(:selected_member_ids, []) |> assign(:selected_members, [])
|> assign(:selected_members, []) |> assign(:show_member_dropdown, false)
|> assign(:show_member_dropdown, false) |> assign(:focused_member_index, nil)
|> assign(:focused_member_index, nil)} |> load_add_member_candidates()
{:noreply, socket}
end end
@impl true @impl true
def handle_event("show_member_dropdown", _params, socket) do def handle_event("show_member_dropdown", _params, socket) do
# Use existing group.members for filtering; reload only on add/remove # Filter in memory from preloaded candidates; no DB read (R2).
query = socket.assigns.member_search_query || ""
socket = socket =
socket socket
|> load_available_members("") |> assign(
:available_members,
filter_candidates_in_memory(socket.assigns.add_member_candidates, query)
)
|> assign(:show_member_dropdown, true) |> assign(:show_member_dropdown, true)
|> assign(:focused_member_index, nil) |> assign(:focused_member_index, nil)
@ -462,6 +472,7 @@ defmodule MvWeb.GroupLive.Show do
|> assign(:show_add_member_input, false) |> assign(:show_add_member_input, false)
|> assign(:member_search_query, "") |> assign(:member_search_query, "")
|> assign(:available_members, []) |> assign(:available_members, [])
|> assign(:add_member_candidates, [])
|> assign(:selected_member_ids, []) |> assign(:selected_member_ids, [])
|> assign(:selected_members, []) |> assign(:selected_members, [])
|> assign(:show_member_dropdown, false) |> assign(:show_member_dropdown, false)
@ -528,11 +539,13 @@ defmodule MvWeb.GroupLive.Show do
@impl true @impl true
def handle_event("search_members", %{"member_search" => query}, socket) do def handle_event("search_members", %{"member_search" => query}, socket) do
# Use existing group.members for filtering; reload only on add/remove # Filter in memory from preloaded candidates; no DB read (R2).
candidates = socket.assigns.add_member_candidates || []
socket = socket =
socket socket
|> assign(:member_search_query, query) |> assign(:member_search_query, query)
|> load_available_members(query) |> assign(:available_members, filter_candidates_in_memory(candidates, query))
|> assign(:show_member_dropdown, true) |> assign(:show_member_dropdown, true)
|> assign(:focused_member_index, nil) |> assign(:focused_member_index, nil)
@ -656,47 +669,69 @@ defmodule MvWeb.GroupLive.Show do
end end
end end
defp load_available_members(socket, query) do # Load candidate members once when opening add-member UI (single DB read).
defp load_add_member_candidates(socket) do
require Ash.Query require Ash.Query
current_member_ids = group_member_ids_set(socket.assigns.group) group = socket.assigns.group
base_query = available_members_base_query(query) exclude_ids = group_member_ids_set(group) |> MapSet.to_list()
# Fetch more than 10, then exclude already-in-group and take 10 (avoids empty dropdown when first N are all in group)
fetch_limit = 50
limited_query = Ash.Query.limit(base_query, fetch_limit)
actor = current_actor(socket) actor = current_actor(socket)
case Ash.read(limited_query, actor: actor, domain: Mv.Membership) do if exclude_ids == [] do
{:ok, members} -> # No members in group; load first N members
available = query =
members Mv.Membership.Member
|> Enum.reject(fn m -> MapSet.member?(current_member_ids, m.id) end) |> Ash.Query.sort([:last_name, :first_name])
|> Enum.take(10) |> Ash.Query.limit(300)
assign(socket, available_members: available) do_load_add_member_candidates(socket, query, actor)
else
query =
Mv.Membership.Member
|> Ash.Query.filter(expr(id not in ^exclude_ids))
|> Ash.Query.sort([:last_name, :first_name])
|> Ash.Query.limit(300)
do_load_add_member_candidates(socket, query, actor)
end
end
defp do_load_add_member_candidates(socket, query, actor) do
case Ash.read(query, actor: actor, domain: Mv.Membership) do
{:ok, candidates} ->
socket
|> assign(:add_member_candidates, candidates)
|> assign(:available_members, Enum.take(candidates, 10))
{:error, error} -> {:error, error} ->
Logger.warning("Failed to load available members for group: #{inspect(error)}") Logger.warning("Failed to load add-member candidates: #{inspect(error)}")
socket socket
|> put_flash(:error, gettext("Could not load member search. Please try again.")) |> put_flash(:error, gettext("Could not load member list. Please try again."))
|> assign(:add_member_candidates, [])
|> assign(:available_members, []) |> assign(:available_members, [])
end end
end end
defp available_members_base_query(query) do # Filter preloaded candidates by query string (name/email). No DB read. R2.
search_query = if query && String.trim(query) != "", do: String.trim(query), else: nil defp filter_candidates_in_memory(candidates, query) when is_list(candidates) do
q = if is_binary(query), do: String.trim(query) |> String.downcase(), else: ""
if search_query do if q == "" do
Mv.Membership.Member candidates |> Enum.take(10)
|> Ash.Query.for_read(:search, %{query: search_query})
else else
Mv.Membership.Member candidates
|> Ash.Query.new() |> Enum.filter(fn m ->
name = MemberHelpers.display_name(m) |> String.downcase()
email = (m.email || "") |> String.downcase()
String.contains?(name, q) or String.contains?(email, q)
end)
|> Enum.take(10)
end end
end end
defp filter_candidates_in_memory(_, _), do: []
defp group_member_ids_set(group) do defp group_member_ids_set(group) do
members = group.members || [] members = group.members || []
members |> Enum.map(& &1.id) |> MapSet.new() members |> Enum.map(& &1.id) |> MapSet.new()
@ -736,6 +771,7 @@ defmodule MvWeb.GroupLive.Show do
|> assign(:show_add_member_input, false) |> assign(:show_add_member_input, false)
|> assign(:member_search_query, "") |> assign(:member_search_query, "")
|> assign(:available_members, []) |> assign(:available_members, [])
|> assign(:add_member_candidates, [])
|> assign(:selected_member_ids, []) |> assign(:selected_member_ids, [])
|> assign(:selected_members, []) |> assign(:selected_members, [])
|> assign(:show_member_dropdown, false) |> assign(:show_member_dropdown, false)

View file

@ -2264,11 +2264,6 @@ msgstr "Nicht berechtigt."
msgid "Could not load data fields. Please check your permissions." msgid "Could not load data fields. Please check your permissions."
msgstr "Datenfelder konnten nicht geladen werden. Bitte überprüfe deine Berechtigungen." msgstr "Datenfelder konnten nicht geladen werden. Bitte überprüfe deine Berechtigungen."
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format
msgid "Could not load member search. Please try again."
msgstr "Mitgliedersuche konnte nicht geladen werden. Bitte versuchen Sie es erneut."
#: lib/mv_web/live/group_live/show.ex #: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Add Member" msgid "Add Member"
@ -2609,17 +2604,7 @@ msgstr "Import"
msgid "Value type cannot be changed after creation" msgid "Value type cannot be changed after creation"
msgstr "Der Wertetyp kann nach dem Erstellen nicht mehr geändert werden." msgstr "Der Wertetyp kann nach dem Erstellen nicht mehr geändert werden."
#~ #: lib/mv_web/live/import_export_live.ex #: lib/mv_web/live/group_live/show.ex
#~ #, elixir-autogen, elixir-format, fuzzy #, elixir-autogen, elixir-format, fuzzy
#~ msgid "Export Members (CSV)" msgid "Could not load member list. Please try again."
#~ msgstr "Mitglieder exportieren (CSV)" msgstr "Mitgliederliste konnte nicht geladen werden. Bitte versuche es erneut."
#~ #: lib/mv_web/live/import_export_live.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Export functionality will be available in a future release."
#~ msgstr "Export-Funktionalität ist im nächsten release verfügbar."
#~ #: lib/mv_web/live/import_export_live.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Import members from CSV files or export member data."
#~ msgstr "Importiere Mitglieder aus CSV-Dateien oder exportiere Mitgliederdaten."

View file

@ -2265,11 +2265,6 @@ msgstr ""
msgid "Could not load data fields. Please check your permissions." msgid "Could not load data fields. Please check your permissions."
msgstr "" msgstr ""
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format
msgid "Could not load member search. Please try again."
msgstr ""
#: lib/mv_web/live/group_live/show.ex #: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Add Member" msgid "Add Member"
@ -2609,3 +2604,8 @@ msgstr ""
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Value type cannot be changed after creation" msgid "Value type cannot be changed after creation"
msgstr "" msgstr ""
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format
msgid "Could not load member list. Please try again."
msgstr ""

View file

@ -2265,11 +2265,6 @@ msgstr ""
msgid "Could not load data fields. Please check your permissions." msgid "Could not load data fields. Please check your permissions."
msgstr "" msgstr ""
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format
msgid "Could not load member search. Please try again."
msgstr ""
#: lib/mv_web/live/group_live/show.ex #: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format, fuzzy #, elixir-autogen, elixir-format, fuzzy
msgid "Add Member" msgid "Add Member"
@ -2610,17 +2605,7 @@ msgstr ""
msgid "Value type cannot be changed after creation" msgid "Value type cannot be changed after creation"
msgstr "" msgstr ""
#~ #: lib/mv_web/live/import_export_live.ex #: lib/mv_web/live/group_live/show.ex
#~ #, elixir-autogen, elixir-format, fuzzy #, elixir-autogen, elixir-format, fuzzy
#~ msgid "Export Members (CSV)" msgid "Could not load member list. Please try again."
#~ msgstr "" msgstr ""
#~ #: lib/mv_web/live/import_export_live.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Export functionality will be available in a future release."
#~ msgstr ""
#~ #: lib/mv_web/live/import_export_live.ex
#~ #, elixir-autogen, elixir-format
#~ msgid "Import members from CSV files or export member data."
#~ msgstr ""