Compare commits

..

30 commits

Author SHA1 Message Date
f08c5d59f3 StatisticsLive: load statistics only in handle_params
Some checks reported errors
continuous-integration/drone/push Build was killed
continuous-integration/drone/promote/production Build is passing
2026-02-12 19:35:48 +01:00
004336fea3 Statistics test: guarantee empty members then assert is_nil for first_join_year 2026-02-12 19:35:48 +01:00
bd4dc86cca StatisticsLiveTest: explicit auth (read_only) and redirect test for own_data 2026-02-12 19:35:48 +01:00
7828fc729f Gettext: add DE translation for Fee types could not be loaded 2026-02-12 19:35:48 +01:00
3eead112b0 Statistics tests: strict first_join_year nil, fee_type_id in URL 2026-02-12 19:35:48 +01:00
4b61289f33 Statistics LiveView: robust URL, load_fee_types error handling, clamp percents 2026-02-12 19:35:48 +01:00
b416944321 Statistics: log Ash errors instead of returning 0/nil silently 2026-02-12 19:35:48 +01:00
490dced8c8 Statistics: member stats independent of fee type 2026-02-12 19:35:48 +01:00
98af2b77ee Add German translations for statistics page 2026-02-12 19:35:48 +01:00
0351ad6a51 Fix create_fee_type default arg warning in StatisticsTest 2026-02-12 19:35:48 +01:00
2beceb539b Update docs and guidelines for statistics feature
- CODE_GUIDELINES.md and feature-roadmap.md
- Add statistics-page-implementation-plan.md
2026-02-12 19:35:48 +01:00
6fd9d00327 Update gettext: extract and add DE/EN for statistics strings 2026-02-12 19:35:48 +01:00
a263cb4954 Pass actor through CycleGenerator so seeds can use admin
- get_actor(opts): use opts[:actor] or system actor
- load_member, do_generate_cycles, create_cycles pass opts
- Seeds pass admin_user_with_role for Ash.load! and cycle updates
2026-02-12 19:35:48 +01:00
6e309622a0 Add StatisticsLive: overview, bars by year, pie chart
- Summary cards: active/inactive members, open amount
- Joins and exits by year (horizontal bars)
- Contributions by year: table with stacked bar above amounts
- Column order: Paid, Unpaid, Suspended, Total; color dots for legend
- All years combined pie chart
- LiveView tests
2026-02-12 19:35:48 +01:00
919a8e4ebd Add statistics route, permissions, and sidebar entry
- /statistics route and PagePaths.statistics
- Permission sets: viewer and admin can access /statistics
- Sidebar link with can_access_page check
- Plug and sidebar tests updated
2026-02-12 19:35:48 +01:00
fd10fe5cf6 Add Statistics module for member and cycle aggregates
- first_join_year, active/inactive counts, joins/exits by year
- cycle_totals_by_year, open_amount_total
- Unit tests for Statistics
2026-02-12 19:35:48 +01:00
82e908a7e4 Merge pull request 'UI for adding and removing members on the group show page' (#401) from feature/ui-for-adding-members-groups into main
Some checks failed
continuous-integration/drone/push Build is failing
Reviewed-on: #401
2026-02-12 15:41:15 +01:00
2f8a6a2768
Merge remote-tracking branch 'origin/main' into feature/ui-for-adding-members-groups
Some checks failed
continuous-integration/drone/push Build is failing
continuous-integration/drone/promote/production Build is passing
2026-02-12 15:16:35 +01:00
900f322422
fix: pr comments
All checks were successful
continuous-integration/drone/push Build is passing
2026-02-12 15:08:40 +01:00
962e12b644 Merge pull request 'Update renovate/renovate Docker tag to v42.96' (#414) from renovate/renovate-renovate-42.x into main
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #414
2026-02-10 17:26:39 +01:00
Renovate Bot
022e33773e chore(deps): update renovate/renovate docker tag to v42.97
Some checks reported errors
continuous-integration/drone/push Build was killed
2026-02-10 17:26:20 +01:00
a88fdaf96f Merge pull request 'chore(deps): update ghcr.io/sebadob/rauthy docker tag to v0.34.3' (#412) from renovate/ghcr.io-sebadob-rauthy-0.x into main
Some checks reported errors
continuous-integration/drone/push Build was killed
Reviewed-on: #412
2026-02-10 17:25:21 +01:00
Renovate Bot
74dfd93fb8 Update ghcr.io/sebadob/rauthy Docker tag to v0.34.3
Some checks reported errors
continuous-integration/drone/push Build was killed
2026-02-10 17:25:00 +01:00
c9ea784c14 Merge pull request 'chore(deps): update mix dependencies' (#411) from renovate/mix-dependencies into main
All checks were successful
continuous-integration/drone/push Build is passing
Reviewed-on: #411
2026-02-10 16:46:04 +01:00
Renovate Bot
b142a3a66a chore(deps): update mix dependencies
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/promote/production Build is passing
2026-02-10 00:27:05 +00:00
e4671e816b
fix: failing test due to merge
Some checks failed
continuous-integration/drone/push Build is failing
continuous-integration/drone/promote/production Build is passing
2026-02-03 16:30:59 +01:00
03f27a5938
Merge remote-tracking branch 'origin/main' into feature/ui-for-adding-members-groups 2026-02-03 16:15:53 +01:00
7f001c55c5
feat: add ui to add members to groups
All checks were successful
continuous-integration/drone/push Build is passing
continuous-integration/drone/promote/production Build is passing
2026-02-03 11:44:08 +01:00
a536485b30
test: add tdd tests for group member add functionality
Some checks failed
continuous-integration/drone/push Build is failing
2026-01-29 17:12:43 +01:00
90758191f9
docs: update groups architecture 2026-01-29 17:03:07 +01:00
18 changed files with 3148 additions and 13 deletions

View file

@ -273,7 +273,7 @@ environment:
steps: steps:
- name: renovate - name: renovate
image: renovate/renovate:42.95 image: renovate/renovate:42.97
environment: environment:
RENOVATE_CONFIG_FILE: "renovate_backend_config.js" RENOVATE_CONFIG_FILE: "renovate_backend_config.js"
RENOVATE_TOKEN: RENOVATE_TOKEN:

View file

@ -25,7 +25,7 @@ services:
rauthy: rauthy:
container_name: rauthy-dev container_name: rauthy-dev
image: ghcr.io/sebadob/rauthy:0.34.2 image: ghcr.io/sebadob/rauthy:0.34.3
environment: environment:
- LOCAL_TEST=true - LOCAL_TEST=true
- SMTP_URL=mailcrab - SMTP_URL=mailcrab

View file

@ -314,9 +314,24 @@ lib/
- Display group name and description - Display group name and description
- List all members in group - List all members in group
- Link to member detail pages - Link to member detail pages
- Add members to group (via inline combobox with search/autocomplete)
- Remove members from group (via remove button per member)
- Edit group button (navigates to `/groups/:slug/edit`) - Edit group button (navigates to `/groups/:slug/edit`)
- Delete group button (with confirmation modal) - Delete group button (with confirmation modal)
**Add Member Functionality:**
- "Add Member" button displayed above member table (only for users with `:update` permission)
- Opens inline add member area with member search/autocomplete (combobox)
- Search filters out members already in the group
- Selecting a member adds them to the group immediately
- Success/error flash messages provide feedback
- "Cancel" button closes the inline add member area without adding
**Remove Member Functionality:**
- "Remove" button (icon button) for each member in table (only for users with `:update` permission)
- Clicking remove immediately removes member from group (no confirmation dialog)
- Success/error flash messages provide feedback
**Note:** Uses slug for routing to provide URL-friendly, readable group URLs (e.g., `/groups/board-members`). **Note:** Uses slug for routing to provide URL-friendly, readable group URLs (e.g., `/groups/board-members`).
### Group Form Pages ### Group Form Pages
@ -754,6 +769,7 @@ Each functional unit can be implemented as a **separate issue**:
- **Issue 4:** Groups in Member Detail (Unit 5) - **Issue 4:** Groups in Member Detail (Unit 5)
- **Issue 5:** Groups in Member Search (Unit 6) - **Issue 5:** Groups in Member Search (Unit 6)
- **Issue 6:** Permissions (Unit 7) - **Issue 6:** Permissions (Unit 7)
- **Issue 7:** Add/Remove Members in Group Detail View
**Alternative:** Issues 3 and 4 can be combined, as they both concern the display of groups. **Alternative:** Issues 3 and 4 can be combined, as they both concern the display of groups.
@ -799,6 +815,27 @@ Each functional unit can be implemented as a **separate issue**:
**Estimation:** 3-4h **Estimation:** 3-4h
### Phase 2a: Add/Remove Members in Group Detail View
**Goal:** Enable adding and removing members from groups via UI
**Tasks:**
1. Add "Add Member" button above member table in Group Detail View
2. Implement inline add member with search/autocomplete
3. Add "Remove" button for each member in table
4. Implement add/remove functionality with flash messages
5. Ensure proper authorization checks
**Deliverables:**
- Members can be added to groups via UI
- Members can be removed from groups via UI
- Proper feedback via flash messages
- Authorization enforced
**Estimation:** 2-3h
**Note:** This phase extends Phase 2 and can be implemented as Issue 7 after Issue 2 is complete.
### Phase 3: Member Overview Integration ### Phase 3: Member Overview Integration
**Goal:** Display and filter groups in member overview **Goal:** Display and filter groups in member overview
@ -865,9 +902,9 @@ Each functional unit can be implemented as a **separate issue**:
**Estimation:** 1-2h **Estimation:** 1-2h
### Total Estimation: 13-18h ### Total Estimation: 15-21h
**Note:** This aligns with the issue estimation of 15h. **Note:** This includes all 7 issues. The original MVP estimation was 13-15h, with Issue 7 adding 2-3h for the add/remove members functionality in the Group Detail View.
--- ---
@ -960,6 +997,55 @@ Each functional unit can be implemented as a **separate issue**:
- Only admins can manage groups - Only admins can manage groups
- All users can view groups (if they can view members) - All users can view groups (if they can view members)
### Issue 7: Add/Remove Members in Group Detail View
**Type:** Frontend
**Estimation:** 2-3h
**Dependencies:** Issue 1 (Backend must be functional), Issue 2 (Group Detail View must exist)
**Tasks:**
- Add "Add Member" button above member table in Group Detail View (`/groups/:slug`)
- Implement inline add member for member selection with search/autocomplete
- Add "Remove" button for each member in the member table
- Implement add member functionality (create MemberGroup association)
- Implement remove member functionality (destroy MemberGroup association)
- Add flash messages for success/error feedback
- Ensure proper authorization checks (only users with `:update` permission on Group can add/remove)
- Filter out members already in the group from search results
- Reload group data after add/remove operations
**Acceptance Criteria:**
- "Add Member" button is visible above member table (only for users with `:update` permission)
- Clicking "Add Member" opens inline add member area with member search/autocomplete
- Search filters members and excludes those already in the group
- Selecting a member from search adds them to the group
- Success flash message is displayed when member is added
- Error flash message is displayed if member is already in group or other error occurs
- Each member row in the table has a "Remove" button (only visible for users with `:update` permission)
- Clicking "Remove" immediately removes the member from the group (no confirmation dialog)
- Success flash message is displayed when member is removed
- Group member list and member count update automatically after add/remove
- Inline add member area closes after successful member addition
- Authorization is enforced server-side in event handlers
- UI respects permission checks (buttons hidden for unauthorized users)
**Technical Notes:**
- Reuse member search pattern from `UserLive.Form` (ComboBox hook with autocomplete)
- Use `Membership.create_member_group/1` for adding members
- Use `Membership.destroy_member_group/1` for removing members
- Filter search results to exclude members already in the group (check `group.members`)
- Reload group with `:members` and `:member_count` after operations
- Use inline combobox pattern (delete group uses a separate confirmation modal)
- Ensure accessibility: proper ARIA labels, keyboard navigation, focus management
**UI/UX Details:**
- Inline add member section (no modal; combobox above member table)
- Search input placeholder: "Search for a member..."
- Search results show member name and email
- "Add" button in inline area (disabled until member selected)
- "Cancel" button to close inline add member area
- Remove button can be an icon button (trash icon) with tooltip
- Flash messages: "Member added successfully" / "Member removed successfully" / error messages
--- ---
## Testing Strategy ## Testing Strategy

View file

@ -15,6 +15,8 @@ defmodule MvWeb.GroupLive.Show do
""" """
use MvWeb, :live_view use MvWeb, :live_view
require Logger
import MvWeb.LiveHelpers, only: [current_actor: 1] import MvWeb.LiveHelpers, only: [current_actor: 1]
import MvWeb.Authorization import MvWeb.Authorization
@ -22,7 +24,15 @@ defmodule MvWeb.GroupLive.Show do
@impl true @impl true
def mount(_params, _session, socket) do def mount(_params, _session, socket) do
{:ok, socket} {:ok,
socket
|> assign(:show_add_member_input, false)
|> assign(:member_search_query, "")
|> assign(:available_members, [])
|> assign(:selected_member_ids, [])
|> assign(:selected_members, [])
|> assign(:show_member_dropdown, false)
|> assign(:focused_member_index, nil)}
end end
@impl true @impl true
@ -122,6 +132,128 @@ defmodule MvWeb.GroupLive.Show do
)} )}
</p> </p>
<%= if can?(@current_user, :update, Mv.Membership.Group) do %>
<div class="mb-4">
<%= if assigns[:show_add_member_input] do %>
<div class="join w-full">
<form phx-change="search_members" class="flex-1">
<div class="relative">
<div class="input input-bordered join-item w-full flex flex-wrap gap-1 items-center py-1 px-2">
<%= for member <- @selected_members do %>
<span class="badge badge-outline badge flex items-center gap-1">
{MvWeb.Helpers.MemberHelpers.display_name(member)}
<button
type="button"
class="btn btn-ghost btn-xs p-0 h-4 w-4 min-h-0"
phx-click="remove_selected_member"
phx-value-member_id={member.id}
aria-label={
gettext("Remove %{name}",
name: MvWeb.Helpers.MemberHelpers.display_name(member)
)
}
>
<.icon name="hero-x-mark" class="size-3" />
</button>
</span>
<% end %>
<input
type="text"
id="member-search-input"
role="combobox"
phx-hook="ComboBox"
phx-focus="show_member_dropdown"
phx-debounce="300"
phx-keydown="member_dropdown_keydown"
phx-mounted={JS.focus()}
value={@member_search_query}
placeholder={
if Enum.empty?(@selected_members),
do: gettext("Search for a member..."),
else: ""
}
class="flex-1 min-w-[120px] border-0 focus:outline-none bg-transparent"
name="member_search"
aria-label={gettext("Search for a member")}
aria-autocomplete="list"
aria-controls="member-dropdown"
aria-expanded={to_string(@show_member_dropdown)}
aria-activedescendant={
if @focused_member_index,
do: "member-option-#{@focused_member_index}",
else: nil
}
autocomplete="off"
/>
</div>
<%= 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 top-full #{if !@show_member_dropdown, do: "hidden"}"}
phx-click-away="hide_member_dropdown"
>
<%= for {member, index} <- Enum.with_index(@available_members) do %>
<div
id={"member-option-#{index}"}
role="option"
tabindex="0"
aria-selected={to_string(@focused_member_index == index)}
phx-click="select_member"
phx-value-id={member.id}
data-member-id={member.id}
class={[
"px-4 py-3 cursor-pointer border-b border-base-300 last:border-b-0",
if(@focused_member_index == index,
do: "bg-base-300",
else: "hover:bg-base-200"
)
]}
>
<p class="font-medium">
{MvWeb.Helpers.MemberHelpers.display_name(member)}
</p>
<p class="text-sm text-base-content/70">
{member.email || gettext("No email")}
</p>
</div>
<% end %>
</div>
<% end %>
</div>
</form>
<button
type="button"
class="btn btn-primary join-item"
phx-click="add_selected_members"
disabled={Enum.empty?(@selected_member_ids)}
aria-label={gettext("Add members")}
>
<.icon name="hero-plus" class="size-5" />
</button>
<button
type="button"
class="btn join-item"
phx-click="hide_add_member_input"
aria-label={gettext("Cancel")}
>
{gettext("Cancel")}
</button>
</div>
<% else %>
<.button
variant="primary"
phx-click="show_add_member_input"
aria-label={gettext("Add Member")}
>
{gettext("Add Member")}
</.button>
<% end %>
</div>
<% end %>
<%= if Enum.empty?(@group.members || []) do %> <%= if Enum.empty?(@group.members || []) do %>
<p class="text-base-content/50 italic">{gettext("No members in this group")}</p> <p class="text-base-content/50 italic">{gettext("No members in this group")}</p>
<% else %> <% else %>
@ -131,6 +263,9 @@ 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 %>
<th class="w-0">{gettext("Actions")}</th>
<% end %>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -156,6 +291,20 @@ 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 %>
<td>
<button
type="button"
class="btn btn-ghost btn-sm text-error"
phx-click="remove_member"
phx-value-member_id={member.id}
aria-label={gettext("Remove member from group")}
data-tooltip={gettext("Remove")}
>
<.icon name="hero-trash" class="size-4" />
</button>
</td>
<% end %>
</tr> </tr>
<% end %> <% end %>
</tbody> </tbody>
@ -236,11 +385,13 @@ defmodule MvWeb.GroupLive.Show do
""" """
end end
# Delete Modal Events
@impl true @impl true
def handle_event("open_delete_modal", _params, socket) do def handle_event("open_delete_modal", _params, socket) do
{:noreply, assign(socket, show_delete_modal: true, name_confirmation: "")} {:noreply, assign(socket, show_delete_modal: true, name_confirmation: "")}
end end
@impl true
def handle_event("cancel_delete", _params, socket) do def handle_event("cancel_delete", _params, socket) do
{:noreply, {:noreply,
socket socket
@ -248,10 +399,12 @@ defmodule MvWeb.GroupLive.Show do
|> assign(:name_confirmation, "")} |> assign(:name_confirmation, "")}
end end
@impl true
def handle_event("update_name_confirmation", %{"name" => name}, socket) do def handle_event("update_name_confirmation", %{"name" => name}, socket) do
{:noreply, assign(socket, :name_confirmation, name)} {:noreply, assign(socket, :name_confirmation, name)}
end end
@impl true
def handle_event("confirm_delete", %{"slug" => slug}, socket) do def handle_event("confirm_delete", %{"slug" => slug}, socket) do
actor = current_actor(socket) actor = current_actor(socket)
group = socket.assigns.group group = socket.assigns.group
@ -275,6 +428,417 @@ defmodule MvWeb.GroupLive.Show do
end end
end end
# Add Member Events
@impl true
def handle_event("show_add_member_input", _params, socket) do
# Reload group to ensure we have the latest members list
actor = current_actor(socket)
group = socket.assigns.group
socket = reload_group(socket, group.slug, actor)
{:noreply,
socket
|> assign(:show_add_member_input, true)
|> assign(:member_search_query, "")
|> assign(:available_members, [])
|> assign(:selected_member_ids, [])
|> assign(:selected_members, [])
|> assign(:show_member_dropdown, false)
|> assign(:focused_member_index, nil)}
end
@impl true
def handle_event("show_member_dropdown", _params, socket) do
# Use existing group.members for filtering; reload only on add/remove
socket =
socket
|> load_available_members("")
|> assign(:show_member_dropdown, true)
|> assign(:focused_member_index, nil)
{:noreply, socket}
end
@impl true
def handle_event("hide_add_member_input", _params, socket) do
{:noreply,
socket
|> assign(:show_add_member_input, false)
|> assign(:member_search_query, "")
|> assign(:available_members, [])
|> assign(:selected_member_ids, [])
|> assign(:selected_members, [])
|> assign(:show_member_dropdown, false)
|> assign(:focused_member_index, nil)}
end
@impl true
def handle_event("hide_member_dropdown", _params, socket) do
{:noreply, assign(socket, show_member_dropdown: false, focused_member_index: nil)}
end
@impl true
def handle_event("member_dropdown_keydown", %{"key" => "ArrowDown"}, socket) do
return_if_dropdown_closed(socket, fn ->
max_index = length(socket.assigns.available_members) - 1
current = socket.assigns.focused_member_index
new_index =
case current do
nil -> 0
index when index < max_index -> index + 1
_ -> current
end
{:noreply, assign(socket, focused_member_index: new_index)}
end)
end
@impl true
def handle_event("member_dropdown_keydown", %{"key" => "ArrowUp"}, socket) do
return_if_dropdown_closed(socket, fn ->
current = socket.assigns.focused_member_index
new_index =
case current do
nil -> 0
0 -> 0
index -> index - 1
end
{:noreply, assign(socket, focused_member_index: new_index)}
end)
end
@impl true
def handle_event("member_dropdown_keydown", %{"key" => "Enter"}, socket) do
return_if_dropdown_closed(socket, fn ->
select_focused_member(socket)
end)
end
@impl true
def handle_event("member_dropdown_keydown", %{"key" => "Escape"}, socket) do
return_if_dropdown_closed(socket, fn ->
{:noreply, assign(socket, show_member_dropdown: false, focused_member_index: nil)}
end)
end
@impl true
def handle_event("member_dropdown_keydown", _params, socket) do
# Ignore other keys
{:noreply, socket}
end
@impl true
def handle_event("search_members", %{"member_search" => query}, socket) do
# Use existing group.members for filtering; reload only on add/remove
socket =
socket
|> assign(:member_search_query, query)
|> load_available_members(query)
|> assign(:show_member_dropdown, true)
|> assign(:focused_member_index, nil)
{:noreply, socket}
end
@impl true
def handle_event("select_member", %{"id" => member_id}, socket) do
# Check if member is already selected
if member_id in socket.assigns.selected_member_ids do
{:noreply, socket}
else
# Find the selected member
selected_member = Enum.find(socket.assigns.available_members, &(&1.id == member_id))
if selected_member do
socket =
socket
|> assign(:selected_member_ids, [member_id | socket.assigns.selected_member_ids])
|> assign(:selected_members, [selected_member | socket.assigns.selected_members])
|> assign(:member_search_query, "")
|> assign(:show_member_dropdown, false)
|> assign(:focused_member_index, nil)
{:noreply, socket}
else
{:noreply, socket}
end
end
end
@impl true
def handle_event("remove_selected_member", %{"member_id" => member_id}, socket) do
socket =
socket
|> assign(:selected_member_ids, List.delete(socket.assigns.selected_member_ids, member_id))
|> assign(
:selected_members,
Enum.reject(socket.assigns.selected_members, &(&1.id == member_id))
)
{:noreply, socket}
end
@impl true
def handle_event("add_selected_members", _params, socket) do
actor = current_actor(socket)
group = socket.assigns.group
# Server-side authorization check
if can?(actor, :update, group) do
member_ids = Enum.uniq(socket.assigns.selected_member_ids)
perform_add_members(socket, group, member_ids, actor)
else
{:noreply,
socket
|> put_flash(:error, gettext("Not authorized."))
|> redirect(to: ~p"/groups/#{group.slug}")}
end
end
@impl true
def handle_event("remove_member", %{"member_id" => member_id}, socket) do
actor = current_actor(socket)
group = socket.assigns.group
# Server-side authorization check
if can?(actor, :update, group) do
perform_remove_member(socket, group, member_id, actor)
else
{:noreply,
socket
|> put_flash(:error, gettext("Not authorized."))
|> redirect(to: ~p"/groups/#{group.slug}")}
end
end
# Helper functions
defp return_if_dropdown_closed(socket, fun) do
if socket.assigns.show_member_dropdown do
fun.()
else
{:noreply, socket}
end
end
defp select_focused_member(socket) do
case socket.assigns.focused_member_index do
nil ->
{:noreply, socket}
index ->
select_member_by_index(socket, index)
end
end
defp select_member_by_index(socket, index) do
case Enum.at(socket.assigns.available_members, index) do
nil ->
{:noreply, socket}
member ->
add_member_to_selection(socket, member)
end
end
defp add_member_to_selection(socket, member) do
# Check if member is already selected
if member.id in socket.assigns.selected_member_ids do
{:noreply, socket}
else
socket =
socket
|> assign(:selected_member_ids, [member.id | socket.assigns.selected_member_ids])
|> assign(:selected_members, [member | socket.assigns.selected_members])
|> assign(:member_search_query, "")
|> assign(:show_member_dropdown, false)
|> assign(:focused_member_index, nil)
{:noreply, socket}
end
end
defp load_available_members(socket, query) do
require Ash.Query
current_member_ids = group_member_ids_set(socket.assigns.group)
base_query = available_members_base_query(query)
# 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)
case Ash.read(limited_query, actor: actor, domain: Mv.Membership) do
{:ok, members} ->
available =
members
|> Enum.reject(fn m -> MapSet.member?(current_member_ids, m.id) end)
|> Enum.take(10)
assign(socket, available_members: available)
{:error, error} ->
Logger.warning("Failed to load available members for group: #{inspect(error)}")
socket
|> put_flash(:error, gettext("Could not load member search. Please try again."))
|> assign(:available_members, [])
end
end
defp available_members_base_query(query) do
search_query = if query && String.trim(query) != "", do: String.trim(query), else: nil
if search_query do
Mv.Membership.Member
|> Ash.Query.for_read(:search, %{query: search_query})
else
Mv.Membership.Member
|> Ash.Query.new()
end
end
defp group_member_ids_set(group) do
members = group.members || []
members |> Enum.map(& &1.id) |> MapSet.new()
end
defp perform_add_members(socket, group, member_ids, actor) when is_list(member_ids) do
# Add all members in a transaction-like manner
results =
Enum.map(member_ids, fn member_id ->
Membership.create_member_group(
%{member_id: member_id, group_id: group.id},
actor: actor
)
end)
# Check for errors
errors = Enum.filter(results, &match?({:error, _}, &1))
if Enum.empty?(errors) do
handle_successful_add_members(socket, group, actor)
else
handle_failed_add_members(socket, group, errors, actor)
end
end
defp perform_add_members(socket, _group, _member_ids, _actor) do
{:noreply,
socket
|> put_flash(:error, gettext("No members selected."))}
end
defp handle_successful_add_members(socket, group, actor) do
socket = reload_group(socket, group.slug, actor)
{:noreply,
socket
|> assign(:show_add_member_input, false)
|> assign(:member_search_query, "")
|> assign(:available_members, [])
|> assign(:selected_member_ids, [])
|> assign(:selected_members, [])
|> assign(:show_member_dropdown, false)
|> assign(:focused_member_index, nil)}
end
defp handle_failed_add_members(socket, group, errors, actor) do
error_messages = extract_error_messages(errors)
# Still reload to show any successful additions
socket = reload_group(socket, group.slug, actor)
{:noreply,
socket
|> put_flash(
:error,
gettext("Some members could not be added: %{errors}", errors: error_messages)
)
|> assign(:show_add_member_input, true)}
end
defp extract_error_messages(errors) do
Enum.map(errors, fn {:error, error} ->
format_single_error(error)
end)
|> Enum.uniq()
|> Enum.join(", ")
end
defp format_single_error(%{errors: [%{message: message}]}) when is_binary(message), do: message
defp format_single_error(%{errors: [%{field: :member_id, message: message}]})
when is_binary(message),
do: message
defp format_single_error(error), do: format_error(error)
defp perform_remove_member(socket, group, member_id, actor) do
require Ash.Query
# Find the MemberGroup association
query =
Mv.Membership.MemberGroup
|> Ash.Query.filter(member_id == ^member_id and group_id == ^group.id)
case Ash.read_one(query, actor: actor, domain: Mv.Membership) do
{:ok, nil} ->
{:noreply,
socket
|> put_flash(:error, gettext("Member is not in this group."))}
{:ok, member_group} ->
case Membership.destroy_member_group(member_group, actor: actor) do
:ok ->
# Reload group with members and member_count
socket = reload_group(socket, group.slug, actor)
{:noreply, socket}
{:error, error} ->
error_message = format_error(error)
{:noreply,
socket
|> put_flash(
:error,
gettext("Failed to remove member: %{error}", error: error_message)
)}
end
{:error, error} ->
error_message = format_error(error)
{:noreply,
socket
|> put_flash(
:error,
gettext("Failed to remove member: %{error}", error: error_message)
)}
end
end
defp reload_group(socket, slug, actor) do
require Ash.Query
query =
Mv.Membership.Group
|> Ash.Query.filter(slug == ^slug)
|> Ash.Query.load([:members, :member_count])
case Ash.read_one(query, actor: actor, domain: Mv.Membership) do
{:ok, group} ->
assign(socket, :group, group)
{:error, _} ->
socket
end
end
defp handle_delete_confirmation(socket, group, actor) do defp handle_delete_confirmation(socket, group, actor) do
if socket.assigns.name_confirmation == group.name do if socket.assigns.name_confirmation == group.name do
perform_group_deletion(socket, group, actor) perform_group_deletion(socket, group, actor)

View file

@ -38,7 +38,7 @@ defmodule Mv.MixProject do
[ [
{:tidewave, "~> 0.5", only: [:dev]}, {:tidewave, "~> 0.5", only: [:dev]},
{:sourceror, "~> 1.8", only: [:dev, :test]}, {:sourceror, "~> 1.8", only: [:dev, :test]},
{:live_debugger, "~> 0.5", only: [:dev]}, {:live_debugger, "~> 0.6", only: [:dev]},
{:ash_admin, "~> 0.13"}, {:ash_admin, "~> 0.13"},
{:ash_postgres, "~> 2.0"}, {:ash_postgres, "~> 2.0"},
{:ash_phoenix, "~> 2.0"}, {:ash_phoenix, "~> 2.0"},

View file

@ -1,11 +1,11 @@
%{ %{
"ash": {:hex, :ash, "3.14.1", "22e0ac5dfd4c7d502bd103f0b4380defd66d7c6c83b3a4f54af7045f13da00d7", [:mix], [{:crux, ">= 0.1.2 and < 1.0.0-0", [hex: :crux, repo: "hexpm", optional: false]}, {:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.7", [hex: :ecto, repo: "hexpm", optional: false]}, {:ets, "~> 0.8", [hex: :ets, repo: "hexpm", optional: false]}, {:igniter, ">= 0.6.29 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: false]}, {:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}, {:plug, ">= 0.0.0", [hex: :plug, repo: "hexpm", optional: true]}, {:reactor, "~> 1.0", [hex: :reactor, repo: "hexpm", optional: false]}, {:simple_sat, ">= 0.1.1 and < 1.0.0-0", [hex: :simple_sat, repo: "hexpm", optional: true]}, {:spark, ">= 2.3.14 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.3", [hex: :splode, repo: "hexpm", optional: false]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.1", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "776a5963790d5af79855ddca1718a037d06b49063a6b97fae9110050b3d5127d"}, "ash": {:hex, :ash, "3.16.0", "6389927b322ca7fa7990a75730133db44fcff6368adb63f41cf9eec7a5d38862", [:mix], [{:crux, ">= 0.1.2 and < 1.0.0-0", [hex: :crux, repo: "hexpm", optional: false]}, {:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:ecto, "~> 3.7", [hex: :ecto, repo: "hexpm", optional: false]}, {:ets, "~> 0.8", [hex: :ets, repo: "hexpm", optional: false]}, {:igniter, ">= 0.6.29 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: false]}, {:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: true]}, {:plug, ">= 0.0.0", [hex: :plug, repo: "hexpm", optional: true]}, {:reactor, "~> 1.0", [hex: :reactor, repo: "hexpm", optional: false]}, {:simple_sat, ">= 0.1.1 and < 1.0.0-0", [hex: :simple_sat, repo: "hexpm", optional: true]}, {:spark, ">= 2.3.14 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.3", [hex: :splode, repo: "hexpm", optional: false]}, {:stream_data, "~> 1.0", [hex: :stream_data, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.1", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "1ea69d932ea2ae6cc2971b92576d8ac2721218a8f2f3599e0e25305edb56949b"},
"ash_admin": {:hex, :ash_admin, "0.13.24", "4fafddc7b4450a92878b58630688c55cab20b0c27e35cad68f29811f73815816", [:mix], [{:ash, ">= 3.4.63 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_phoenix, ">= 2.1.8 and < 3.0.0-0", [hex: :ash_phoenix, repo: "hexpm", optional: false]}, {:gettext, "~> 0.26 or ~> 1.0", [hex: :gettext, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.7", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.1-rc", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}], "hexpm", "8f298cf6cb6635744ed75dd401295ed52513ea4df169f0f89d6a9a75dc4de4dc"}, "ash_admin": {:hex, :ash_admin, "0.13.24", "4fafddc7b4450a92878b58630688c55cab20b0c27e35cad68f29811f73815816", [:mix], [{:ash, ">= 3.4.63 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_phoenix, ">= 2.1.8 and < 3.0.0-0", [hex: :ash_phoenix, repo: "hexpm", optional: false]}, {:gettext, "~> 0.26 or ~> 1.0", [hex: :gettext, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.7", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.1-rc", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}], "hexpm", "8f298cf6cb6635744ed75dd401295ed52513ea4df169f0f89d6a9a75dc4de4dc"},
"ash_authentication": {:hex, :ash_authentication, "4.13.7", "421b5ddb516026f6794435980a632109ec116af2afa68a45e15fb48b41c92cfa", [:mix], [{:argon2_elixir, "~> 4.0", [hex: :argon2_elixir, repo: "hexpm", optional: true]}, {:ash, "~> 3.7", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_postgres, ">= 2.6.8 and < 3.0.0-0", [hex: :ash_postgres, repo: "hexpm", optional: true]}, {:assent, "> 0.2.0 and < 0.3.0", [hex: :assent, repo: "hexpm", optional: false]}, {:bcrypt_elixir, "~> 3.0", [hex: :bcrypt_elixir, repo: "hexpm", optional: false]}, {:castore, "~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:finch, "~> 0.19", [hex: :finch, repo: "hexpm", optional: false]}, {:igniter, "~> 0.4", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:joken, "~> 2.5", [hex: :joken, repo: "hexpm", optional: false]}, {:plug, "~> 1.13", [hex: :plug, repo: "hexpm", optional: false]}, {:spark, "~> 2.0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.2", [hex: :splode, repo: "hexpm", optional: false]}], "hexpm", "0d45ac3fdcca6902dabbe161ce63e9cea8f90583863c2e14261c9309e5837121"}, "ash_authentication": {:hex, :ash_authentication, "4.13.7", "421b5ddb516026f6794435980a632109ec116af2afa68a45e15fb48b41c92cfa", [:mix], [{:argon2_elixir, "~> 4.0", [hex: :argon2_elixir, repo: "hexpm", optional: true]}, {:ash, "~> 3.7", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_postgres, ">= 2.6.8 and < 3.0.0-0", [hex: :ash_postgres, repo: "hexpm", optional: true]}, {:assent, "> 0.2.0 and < 0.3.0", [hex: :assent, repo: "hexpm", optional: false]}, {:bcrypt_elixir, "~> 3.0", [hex: :bcrypt_elixir, repo: "hexpm", optional: false]}, {:castore, "~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:finch, "~> 0.19", [hex: :finch, repo: "hexpm", optional: false]}, {:igniter, "~> 0.4", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: false]}, {:joken, "~> 2.5", [hex: :joken, repo: "hexpm", optional: false]}, {:plug, "~> 1.13", [hex: :plug, repo: "hexpm", optional: false]}, {:spark, "~> 2.0", [hex: :spark, repo: "hexpm", optional: false]}, {:splode, "~> 0.2", [hex: :splode, repo: "hexpm", optional: false]}], "hexpm", "0d45ac3fdcca6902dabbe161ce63e9cea8f90583863c2e14261c9309e5837121"},
"ash_authentication_phoenix": {:hex, :ash_authentication_phoenix, "2.15.0", "89e71e96a3d954aed7ed0c1f511d42cbfd19009b813f580b12749b01bbea5148", [:mix], [{:ash, "~> 3.0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_authentication, "~> 4.10", [hex: :ash_authentication, repo: "hexpm", optional: false]}, {:ash_phoenix, ">= 2.3.11 and < 3.0.0-0", [hex: :ash_phoenix, repo: "hexpm", optional: false]}, {:bcrypt_elixir, "~> 3.0", [hex: :bcrypt_elixir, repo: "hexpm", optional: false]}, {:gettext, "~> 0.26 or ~> 1.0", [hex: :gettext, repo: "hexpm", optional: true]}, {:igniter, ">= 0.5.25 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.6", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_html_helpers, "~> 1.0", [hex: :phoenix_html_helpers, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.1", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}, {:slugify, "~> 1.3", [hex: :slugify, repo: "hexpm", optional: false]}], "hexpm", "d2da66dcf62bc1054ce8f5d9c2829b1dff1dbc3f1d03f9ef0cbe89123d7df107"}, "ash_authentication_phoenix": {:hex, :ash_authentication_phoenix, "2.15.0", "89e71e96a3d954aed7ed0c1f511d42cbfd19009b813f580b12749b01bbea5148", [:mix], [{:ash, "~> 3.0", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_authentication, "~> 4.10", [hex: :ash_authentication, repo: "hexpm", optional: false]}, {:ash_phoenix, ">= 2.3.11 and < 3.0.0-0", [hex: :ash_phoenix, repo: "hexpm", optional: false]}, {:bcrypt_elixir, "~> 3.0", [hex: :bcrypt_elixir, repo: "hexpm", optional: false]}, {:gettext, "~> 0.26 or ~> 1.0", [hex: :gettext, repo: "hexpm", optional: true]}, {:igniter, ">= 0.5.25 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:phoenix, "~> 1.6", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_html_helpers, "~> 1.0", [hex: :phoenix_html_helpers, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.1", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}, {:slugify, "~> 1.3", [hex: :slugify, repo: "hexpm", optional: false]}], "hexpm", "d2da66dcf62bc1054ce8f5d9c2829b1dff1dbc3f1d03f9ef0cbe89123d7df107"},
"ash_phoenix": {:hex, :ash_phoenix, "2.3.19", "244b24256a7d730e5223f36f371a95971542a547a12f0fb73406f67977e86c97", [:mix], [{:ash, ">= 3.5.13 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:igniter, "~> 0.6", [hex: :igniter, repo: "hexpm", optional: true]}, {:inertia, "~> 2.3", [hex: :inertia, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.5.6 or ~> 1.6", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.20.3 or ~> 1.0-rc.1", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:spark, ">= 2.2.29 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}], "hexpm", "754a7d869a3961a927abb7ff700af9895d2e69dd3b8f9471b0aa8e859cc4b135"}, "ash_phoenix": {:hex, :ash_phoenix, "2.3.19", "244b24256a7d730e5223f36f371a95971542a547a12f0fb73406f67977e86c97", [:mix], [{:ash, ">= 3.5.13 and < 4.0.0-0", [hex: :ash, repo: "hexpm", optional: false]}, {:igniter, "~> 0.6", [hex: :igniter, repo: "hexpm", optional: true]}, {:inertia, "~> 2.3", [hex: :inertia, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.5.6 or ~> 1.6", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_html, "~> 4.0", [hex: :phoenix_html, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.20.3 or ~> 1.0-rc.1", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}, {:spark, ">= 2.2.29 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}], "hexpm", "754a7d869a3961a927abb7ff700af9895d2e69dd3b8f9471b0aa8e859cc4b135"},
"ash_postgres": {:hex, :ash_postgres, "2.6.29", "93c7d39890930548acc704613b7f83e65c0880940be1b2048ee86dfb44918529", [:mix], [{:ash, "~> 3.14", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_sql, ">= 0.4.3 and < 1.0.0-0", [hex: :ash_sql, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.13", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:igniter, ">= 0.6.29 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:postgrex, ">= 0.0.0", [hex: :postgrex, repo: "hexpm", optional: false]}, {:spark, ">= 2.3.4 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}], "hexpm", "0aed7ac3d8407ff094218b1dc86b88ea7e39249fb9e94360c7dac1711e206d8b"}, "ash_postgres": {:hex, :ash_postgres, "2.6.31", "2fde375f7ff5b0a4d1ec54d64089e65c4460ff08be222119e7587b820ebd782b", [:mix], [{:ash, "~> 3.15", [hex: :ash, repo: "hexpm", optional: false]}, {:ash_sql, ">= 0.4.3 and < 1.0.0-0", [hex: :ash_sql, repo: "hexpm", optional: false]}, {:ecto, "~> 3.13", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.13", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:igniter, ">= 0.6.29 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:postgrex, ">= 0.0.0", [hex: :postgrex, repo: "hexpm", optional: false]}, {:spark, ">= 2.3.4 and < 3.0.0-0", [hex: :spark, repo: "hexpm", optional: false]}], "hexpm", "0f045d905fe63eb6d43313309dded5db294e437fb8e9ddcf769d4f838b9c5274"},
"ash_sql": {:hex, :ash_sql, "0.4.3", "2c74e0a19646e3d31a384a2712fc48a82d04ceea74467771ce496fd64dbb55db", [:mix], [{:ash, "~> 3.7", [hex: :ash, repo: "hexpm", optional: false]}, {:ecto, ">= 3.13.4 and < 4.0.0-0", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.9", [hex: :ecto_sql, repo: "hexpm", optional: false]}], "hexpm", "b0ecc00502178407e607ae4bcfd2f264f36f6a884218024b5e4d5b3dcfa5e027"}, "ash_sql": {:hex, :ash_sql, "0.4.4", "7e8943b984ad416ba46d297fea6b4d2bcea25c8dfe5666e22d14c42182907798", [:mix], [{:ash, "~> 3.7", [hex: :ash, repo: "hexpm", optional: false]}, {:ecto, ">= 3.13.4 and < 4.0.0-0", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.9", [hex: :ecto_sql, repo: "hexpm", optional: false]}], "hexpm", "19859ba3f111f1e6e4b0b9ab2f7d849e17b6b0ea5dc54811b3e2b54a7ddff5c0"},
"assent": {:hex, :assent, "0.2.13", "11226365d2d8661d23e9a2cf94d3255e81054ff9d88ac877f28bfdf38fa4ef31", [:mix], [{:certifi, ">= 0.0.0", [hex: :certifi, repo: "hexpm", optional: true]}, {:finch, "~> 0.15", [hex: :finch, repo: "hexpm", optional: true]}, {:jose, "~> 1.8", [hex: :jose, repo: "hexpm", optional: true]}, {:mint, "~> 1.0", [hex: :mint, repo: "hexpm", optional: true]}, {:req, "~> 0.4", [hex: :req, repo: "hexpm", optional: true]}, {:ssl_verify_fun, ">= 0.0.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: true]}], "hexpm", "bf9f351b01dd6bceea1d1f157f05438f6765ce606e6eb8d29296003d29bf6eab"}, "assent": {:hex, :assent, "0.2.13", "11226365d2d8661d23e9a2cf94d3255e81054ff9d88ac877f28bfdf38fa4ef31", [:mix], [{:certifi, ">= 0.0.0", [hex: :certifi, repo: "hexpm", optional: true]}, {:finch, "~> 0.15", [hex: :finch, repo: "hexpm", optional: true]}, {:jose, "~> 1.8", [hex: :jose, repo: "hexpm", optional: true]}, {:mint, "~> 1.0", [hex: :mint, repo: "hexpm", optional: true]}, {:req, "~> 0.4", [hex: :req, repo: "hexpm", optional: true]}, {:ssl_verify_fun, ">= 0.0.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: true]}], "hexpm", "bf9f351b01dd6bceea1d1f157f05438f6765ce606e6eb8d29296003d29bf6eab"},
"bandit": {:hex, :bandit, "1.10.2", "d15ea32eb853b5b42b965b24221eb045462b2ba9aff9a0bda71157c06338cbff", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "27b2a61b647914b1726c2ced3601473be5f7aa6bb468564a688646a689b3ee45"}, "bandit": {:hex, :bandit, "1.10.2", "d15ea32eb853b5b42b965b24221eb045462b2ba9aff9a0bda71157c06338cbff", [:mix], [{:hpax, "~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.18", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 1.0", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "27b2a61b647914b1726c2ced3601473be5f7aa6bb468564a688646a689b3ee45"},
"bcrypt_elixir": {:hex, :bcrypt_elixir, "3.3.2", "d50091e3c9492d73e17fc1e1619a9b09d6a5ef99160eb4d736926fd475a16ca3", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "471be5151874ae7931911057d1467d908955f93554f7a6cd1b7d804cac8cef53"}, "bcrypt_elixir": {:hex, :bcrypt_elixir, "3.3.2", "d50091e3c9492d73e17fc1e1619a9b09d6a5ef99160eb4d736926fd475a16ca3", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "471be5151874ae7931911057d1467d908955f93554f7a6cd1b7d804cac8cef53"},
@ -40,9 +40,9 @@
"jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"},
"joken": {:hex, :joken, "2.6.2", "5daaf82259ca603af4f0b065475099ada1b2b849ff140ccd37f4b6828ca6892a", [:mix], [{:jose, "~> 1.11.10", [hex: :jose, repo: "hexpm", optional: false]}], "hexpm", "5134b5b0a6e37494e46dbf9e4dad53808e5e787904b7c73972651b51cce3d72b"}, "joken": {:hex, :joken, "2.6.2", "5daaf82259ca603af4f0b065475099ada1b2b849ff140ccd37f4b6828ca6892a", [:mix], [{:jose, "~> 1.11.10", [hex: :jose, repo: "hexpm", optional: false]}], "hexpm", "5134b5b0a6e37494e46dbf9e4dad53808e5e787904b7c73972651b51cce3d72b"},
"jose": {:hex, :jose, "1.11.12", "06e62b467b61d3726cbc19e9b5489f7549c37993de846dfb3ee8259f9ed208b3", [:mix, :rebar3], [], "hexpm", "31e92b653e9210b696765cdd885437457de1add2a9011d92f8cf63e4641bab7b"}, "jose": {:hex, :jose, "1.11.12", "06e62b467b61d3726cbc19e9b5489f7549c37993de846dfb3ee8259f9ed208b3", [:mix, :rebar3], [], "hexpm", "31e92b653e9210b696765cdd885437457de1add2a9011d92f8cf63e4641bab7b"},
"lazy_html": {:hex, :lazy_html, "0.1.8", "677a8642e644eef8de98f3040e2520d42d0f0f8bd6c5cd49db36504e34dffe91", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.9.0", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:fine, "~> 0.1.0", [hex: :fine, repo: "hexpm", optional: false]}], "hexpm", "0d8167d930b704feb94b41414ca7f5779dff9bca7fcf619fcef18de138f08736"}, "lazy_html": {:hex, :lazy_html, "0.1.10", "ffe42a0b4e70859cf21a33e12a251e0c76c1dff76391609bd56702a0ef5bc429", [:make, :mix], [{:cc_precompiler, "~> 0.1", [hex: :cc_precompiler, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.9.0", [hex: :elixir_make, repo: "hexpm", optional: false]}, {:fine, "~> 0.1.0", [hex: :fine, repo: "hexpm", optional: false]}], "hexpm", "50f67e5faa09d45a99c1ddf3fac004f051997877dc8974c5797bb5ccd8e27058"},
"libgraph": {:hex, :libgraph, "0.16.0", "3936f3eca6ef826e08880230f806bfea13193e49bf153f93edcf0239d4fd1d07", [:mix], [], "hexpm", "41ca92240e8a4138c30a7e06466acc709b0cbb795c643e9e17174a178982d6bf"}, "libgraph": {:hex, :libgraph, "0.16.0", "3936f3eca6ef826e08880230f806bfea13193e49bf153f93edcf0239d4fd1d07", [:mix], [], "hexpm", "41ca92240e8a4138c30a7e06466acc709b0cbb795c643e9e17174a178982d6bf"},
"live_debugger": {:hex, :live_debugger, "0.5.1", "7302a4fda1920ba541b456c2d7a97acc3c7f9d7b938b5435927883b709c968a2", [:mix], [{:igniter, ">= 0.5.40 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.7", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.20.8 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}], "hexpm", "797fdca7cc60d7588c6e285b0d7ea73f2dce8b123bac43eae70271fa519bb907"}, "live_debugger": {:hex, :live_debugger, "0.6.0", "77fcbb11b1909ff6edc29a755aa5f14cb176d188b24593526b3e482be7519990", [:mix], [{:file_system, "~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:igniter, ">= 0.5.40 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:phoenix, "~> 1.7", [hex: :phoenix, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.20.8 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: false]}], "hexpm", "ad2458f4acd8b86e15b1cf7aef3304907e858f4ac35644986e5c958ea993ffb3"},
"luhn": {:hex, :luhn, "0.3.3", "5aa0c6a32c2db4b9db9f9b883ba8301c1ae169d57199b9e6cb1ba2707bc51d96", [:mix], [], "hexpm", "3e823a913a25aab51352c727f135278d22954874d5f0835be81ed4fec3daf78d"}, "luhn": {:hex, :luhn, "0.3.3", "5aa0c6a32c2db4b9db9f9b883ba8301c1ae169d57199b9e6cb1ba2707bc51d96", [:mix], [], "hexpm", "3e823a913a25aab51352c727f135278d22954874d5f0835be81ed4fec3daf78d"},
"mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"},
"mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"}, "mint": {:hex, :mint, "1.7.1", "113fdb2b2f3b59e47c7955971854641c61f378549d73e829e1768de90fc1abf1", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1 or ~> 0.2.0 or ~> 1.0", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "fceba0a4d0f24301ddee3024ae116df1c3f4bb7a563a731f45fdfeb9d39a231b"},
@ -72,7 +72,7 @@
"sobelow": {:hex, :sobelow, "0.14.1", "2f81e8632f15574cba2402bcddff5497b413c01e6f094bc0ab94e83c2f74db81", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "8fac9a2bd90fdc4b15d6fca6e1608efb7f7c600fa75800813b794ee9364c87f2"}, "sobelow": {:hex, :sobelow, "0.14.1", "2f81e8632f15574cba2402bcddff5497b413c01e6f094bc0ab94e83c2f74db81", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "8fac9a2bd90fdc4b15d6fca6e1608efb7f7c600fa75800813b794ee9364c87f2"},
"sourceror": {:hex, :sourceror, "1.10.1", "325753ed460fe9fa34ebb4deda76d57b2e1507dcd78a5eb9e1c41bfb78b7cdfe", [:mix], [], "hexpm", "288f3079d93865cd1e3e20df5b884ef2cb440e0e03e8ae393624ee8a770ba588"}, "sourceror": {:hex, :sourceror, "1.10.1", "325753ed460fe9fa34ebb4deda76d57b2e1507dcd78a5eb9e1c41bfb78b7cdfe", [:mix], [], "hexpm", "288f3079d93865cd1e3e20df5b884ef2cb440e0e03e8ae393624ee8a770ba588"},
"spark": {:hex, :spark, "2.4.0", "f93d3ae6b5f3004e956d52f359fa40670366685447631bc7c058f4fbf250ebf3", [:mix], [{:igniter, ">= 0.3.64 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: true]}, {:sourceror, "~> 1.2", [hex: :sourceror, repo: "hexpm", optional: true]}], "hexpm", "4e5185f5737cd987bb9ef377ae3462a55b8312f5007c2bc4ad6e850d14ac0111"}, "spark": {:hex, :spark, "2.4.0", "f93d3ae6b5f3004e956d52f359fa40670366685447631bc7c058f4fbf250ebf3", [:mix], [{:igniter, ">= 0.3.64 and < 1.0.0-0", [hex: :igniter, repo: "hexpm", optional: true]}, {:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: true]}, {:sourceror, "~> 1.2", [hex: :sourceror, repo: "hexpm", optional: true]}], "hexpm", "4e5185f5737cd987bb9ef377ae3462a55b8312f5007c2bc4ad6e850d14ac0111"},
"spitfire": {:hex, :spitfire, "0.3.1", "409b5ed3a2677df8790ed8b0542ca7e36c607d744fef4cb8cb8872fc80dd1803", [:mix], [], "hexpm", "72ff34d8f0096313a4b1a6505513c5ef4bbc0919bd8c181c07fc8d8dea8c9056"}, "spitfire": {:hex, :spitfire, "0.3.3", "be195b27648f21454932bf46014455cdbce4fca55fef1f0e41d36076c47b6c4a", [], [], "hexpm", "5dc51c3b61a1d98cdcac1c130f0a374d22d51beed982df90834bdd616356e1fa"},
"splode": {:hex, :splode, "0.3.0", "ff8effecc509a51245df2f864ec78d849248647c37a75886033e3b1a53ca9470", [:mix], [], "hexpm", "73cfd0892d7316d6f2c93e6e8784bd6e137b2aa38443de52fd0a25171d106d81"}, "splode": {:hex, :splode, "0.3.0", "ff8effecc509a51245df2f864ec78d849248647c37a75886033e3b1a53ca9470", [:mix], [], "hexpm", "73cfd0892d7316d6f2c93e6e8784bd6e137b2aa38443de52fd0a25171d106d81"},
"stream_data": {:hex, :stream_data, "1.2.0", "58dd3f9e88afe27dc38bef26fce0c84a9e7a96772b2925c7b32cd2435697a52b", [:mix], [], "hexpm", "eb5c546ee3466920314643edf68943a5b14b32d1da9fe01698dc92b73f89a9ed"}, "stream_data": {:hex, :stream_data, "1.2.0", "58dd3f9e88afe27dc38bef26fce0c84a9e7a96772b2925c7b32cd2435697a52b", [:mix], [], "hexpm", "eb5c546ee3466920314643edf68943a5b14b32d1da9fe01698dc92b73f89a9ed"},
"sweet_xml": {:hex, :sweet_xml, "0.7.5", "803a563113981aaac202a1dbd39771562d0ad31004ddbfc9b5090bdcd5605277", [:mix], [], "hexpm", "193b28a9b12891cae351d81a0cead165ffe67df1b73fe5866d10629f4faefb12"}, "sweet_xml": {:hex, :sweet_xml, "0.7.5", "803a563113981aaac202a1dbd39771562d0ad31004ddbfc9b5090bdcd5605277", [:mix], [], "hexpm", "193b28a9b12891cae351d81a0cead165ffe67df1b73fe5866d10629f4faefb12"},

View file

@ -12,6 +12,7 @@ msgstr ""
#: lib/mv_web/components/core_components.ex #: lib/mv_web/components/core_components.ex
#: lib/mv_web/live/group_live/index.ex #: lib/mv_web/live/group_live/index.ex
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Actions" msgid "Actions"
msgstr "Aktionen" msgstr "Aktionen"
@ -672,6 +673,7 @@ msgstr "Einstellungen erfolgreich gespeichert"
msgid "A member with this email already exists. To link with a different member, please change one of the email addresses first." msgid "A member with this email already exists. To link with a different member, please change one of the email addresses first."
msgstr "Ein Mitglied mit dieser E-Mail-Adresse existiert bereits. Um mit einem anderen Mitglied zu verknüpfen, ändere bitte zuerst eine der E-Mail-Adressen." msgstr "Ein Mitglied mit dieser E-Mail-Adresse existiert bereits. Um mit einem anderen Mitglied zu verknüpfen, ändere bitte zuerst eine der E-Mail-Adressen."
#: lib/mv_web/live/group_live/show.ex
#: lib/mv_web/live/user_live/form.ex #: lib/mv_web/live/user_live/form.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Available members" msgid "Available members"
@ -2253,6 +2255,71 @@ 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
#, elixir-autogen, elixir-format
msgid "Add Member"
msgstr "Mitglied hinzufügen"
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format
msgid "Failed to remove member: %{error}"
msgstr "Mitglied konnte nicht entfernt werden: %{error}"
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format
msgid "Member is not in this group."
msgstr "Mitglied ist nicht in dieser Gruppe."
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format
msgid "No email"
msgstr "Keine E-Mail"
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format
msgid "Remove"
msgstr "Entfernen"
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format
msgid "Remove member from group"
msgstr "Mitglied aus Gruppe entfernen"
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format
msgid "Search for a member"
msgstr "Nach einem Mitglied suchen"
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format
msgid "Search for a member..."
msgstr "Nach einem Mitglied suchen..."
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format
msgid "Add members"
msgstr "Mitglieder hinzufügen"
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format
msgid "No members selected."
msgstr "Keine Mitglieder ausgewählt."
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format
msgid "Remove %{name}"
msgstr "%{name} entfernen"
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format
msgid "Some members could not be added: %{errors}"
msgstr "Einige Mitglieder konnten nicht hinzugefügt werden: %{errors}"
#: lib/mv_web/live/import_export_live/components.ex #: lib/mv_web/live/import_export_live/components.ex
#, elixir-autogen, elixir-format, fuzzy #, elixir-autogen, elixir-format, fuzzy
msgid "CSV files only, maximum %{size} MB" msgid "CSV files only, maximum %{size} MB"

View file

@ -13,6 +13,7 @@ msgstr ""
#: lib/mv_web/components/core_components.ex #: lib/mv_web/components/core_components.ex
#: lib/mv_web/live/group_live/index.ex #: lib/mv_web/live/group_live/index.ex
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Actions" msgid "Actions"
msgstr "" msgstr ""
@ -673,6 +674,7 @@ msgstr ""
msgid "A member with this email already exists. To link with a different member, please change one of the email addresses first." msgid "A member with this email already exists. To link with a different member, please change one of the email addresses first."
msgstr "" msgstr ""
#: lib/mv_web/live/group_live/show.ex
#: lib/mv_web/live/user_live/form.ex #: lib/mv_web/live/user_live/form.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Available members" msgid "Available members"
@ -2254,8 +2256,73 @@ 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/import_export_live/components.ex #: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format #, elixir-autogen, elixir-format
msgid "Could not load member search. Please try again."
msgstr ""
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format
msgid "Add Member"
msgstr ""
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format
msgid "Failed to remove member: %{error}"
msgstr ""
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format
msgid "Member is not in this group."
msgstr ""
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format
msgid "No email"
msgstr ""
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format
msgid "Remove"
msgstr ""
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format
msgid "Remove member from group"
msgstr ""
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format
msgid "Search for a member"
msgstr ""
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format
msgid "Search for a member..."
msgstr ""
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format
msgid "Add members"
msgstr ""
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format
msgid "No members selected."
msgstr ""
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format
msgid "Remove %{name}"
msgstr ""
#: lib/mv_web/live/group_live/show.ex
#, elixir-autogen, elixir-format
msgid "Some members could not be added: %{errors}"
msgstr ""
#: lib/mv_web/live/import_export_live/components.ex
#, elixir-autogen, elixir-format, fuzzy
msgid "CSV files only, maximum %{size} MB" msgid "CSV files only, maximum %{size} MB"
msgstr "" msgstr ""

View file

@ -2254,6 +2254,11 @@ 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/import_export_live/components.ex #: lib/mv_web/live/import_export_live/components.ex
#, elixir-autogen, elixir-format, fuzzy #, elixir-autogen, elixir-format, fuzzy
msgid "CSV files only, maximum %{size} MB" msgid "CSV files only, maximum %{size} MB"

View file

@ -0,0 +1,301 @@
defmodule MvWeb.GroupLive.ShowAccessibilityTest do
@moduledoc """
Accessibility tests for Add/Remove Member functionality.
Tests ARIA labels, keyboard navigation, and screen reader support.
"""
use MvWeb.ConnCase, async: false
import Phoenix.LiveViewTest
use Gettext, backend: MvWeb.Gettext
alias Mv.Membership
alias Mv.Fixtures
describe "ARIA labels and roles" do
test "search input has proper ARIA attributes", %{conn: conn} do
group = Fixtures.group_fixture()
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Open inline input
view
|> element("button", "Add Member")
|> render_click()
html = render(view)
# Search input should have proper ARIA attributes
assert html =~ ~r/aria-label/ ||
html =~ ~r/aria-autocomplete/ ||
html =~ ~r/role=["']combobox["']/
end
test "search input has correct aria-label and aria-autocomplete attributes", %{conn: conn} do
group = Fixtures.group_fixture()
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Open inline input
view
|> element("button", "Add Member")
|> render_click()
html = render(view)
# Search input should have ARIA attributes
assert html =~ ~r/aria-label.*[Ss]earch.*member/ ||
html =~ ~r/aria-autocomplete=["']list["']/
end
test "remove button has aria-label with tooltip text", %{conn: conn} do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
group = Fixtures.group_fixture()
{:ok, member} =
Membership.create_member(
%{
first_name: "Alice",
last_name: "Smith",
email: "alice@example.com"
},
actor: system_actor
)
Membership.create_member_group(%{member_id: member.id, group_id: group.id},
actor: system_actor
)
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
html = render(view)
# Remove button should have aria-label
assert html =~ ~r/aria-label.*[Rr]emove/ ||
html =~ ~r/aria-label.*member/i
end
test "add button has correct aria-label", %{conn: conn} do
group = Fixtures.group_fixture()
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Open inline input
view
|> element("button", "Add Member")
|> render_click()
html = render(view)
# Add button should have aria-label
assert html =~ ~r/aria-label.*[Aa]dd/ ||
html =~ ~r/button.*[Aa]dd/
end
end
describe "keyboard navigation" do
test "tab navigation works in inline add member area", %{conn: conn} do
# This test verifies that keyboard navigation is possible
# Actual tab order testing would require more complex setup
group = Fixtures.group_fixture()
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Open inline input
view
|> element("button", "Add Member")
|> render_click()
html = render(view)
# Inline add member area should have focusable elements
assert html =~ ~r/input|button/ ||
html =~ "#member-search-input"
end
test "inline input can be closed", %{conn: conn} do
group = Fixtures.group_fixture()
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Open inline input
view
|> element("button", "Add Member")
|> render_click()
assert has_element?(view, "#member-search-input")
# Click Add Member button again to close (or add a member to close it)
# For now, we verify the input is visible when opened
html = render(view)
assert html =~ "#member-search-input" || has_element?(view, "#member-search-input")
end
test "enter/space activates buttons when focused", %{conn: conn} do
# This test verifies that buttons can be activated via keyboard
# Actual keyboard event testing would require more complex setup
system_actor = Mv.Helpers.SystemActor.get_system_actor()
group = Fixtures.group_fixture()
{:ok, member} =
Membership.create_member(
%{
first_name: "Bob",
last_name: "Jones",
email: "bob@example.com"
},
actor: system_actor
)
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Open inline input
view
|> element("button", "Add Member")
|> render_click()
# Select member
view
|> element("#member-search-input")
|> render_focus()
# phx-change is on the form, so we need to trigger it via the form
view
|> element("form[phx-change='search_members']")
|> render_change(%{"member_search" => "Bob"})
view
|> element("[data-member-id='#{member.id}']")
|> render_click()
# Add button should be enabled and clickable
view
|> element("button[phx-click='add_selected_members']")
|> render_click()
# Should succeed (member should appear in list)
html = render(view)
assert html =~ "Bob"
end
test "focus management: focus is set to input when opened", %{conn: conn} do
# This test verifies that focus is properly managed
# When inline input opens, focus should move to input field
group = Fixtures.group_fixture()
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Open inline input
view
|> element("button", "Add Member")
|> render_click()
html = render(view)
# Input should be visible and focusable
assert html =~ "#member-search-input" ||
html =~ ~r/autofocus|tabindex/
end
end
describe "screen reader support" do
test "search input has proper label for screen readers", %{conn: conn} do
group = Fixtures.group_fixture()
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Open inline input
view
|> element("button", "Add Member")
|> render_click()
html = render(view)
# Input should have aria-label
assert html =~ ~r/aria-label.*[Ss]earch.*member/ ||
html =~ ~r/aria-label/
end
test "search results are properly announced", %{conn: conn} do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
group = Fixtures.group_fixture()
{:ok, _member} =
Membership.create_member(
%{
first_name: "Charlie",
last_name: "Brown",
email: "charlie@example.com"
},
actor: system_actor
)
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Open inline input
view
|> element("button", "Add Member")
|> render_click()
# Search
view
|> element("#member-search-input")
|> render_focus()
# phx-change is on the form, so we need to trigger it via the form
view
|> element("form[phx-change='search_members']")
|> render_change(%{"member_search" => "Charlie"})
html = render(view)
# Search results should have proper ARIA attributes
assert html =~ ~r/role=["']listbox["']/ ||
html =~ ~r/role=["']option["']/ ||
html =~ "Charlie"
end
test "flash messages are properly announced", %{conn: conn} do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
group = Fixtures.group_fixture()
{:ok, member} =
Membership.create_member(
%{
first_name: "David",
last_name: "Wilson",
email: "david@example.com"
},
actor: system_actor
)
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Add member
view
|> element("button", "Add Member")
|> render_click()
view
|> element("#member-search-input")
|> render_focus()
# phx-change is on the form, so we need to trigger it via the form
view
|> element("form[phx-change='search_members']")
|> render_change(%{"member_search" => "David"})
view
|> element("[data-member-id='#{member.id}']")
|> render_click()
view
|> element("button[phx-click='add_selected_members']")
|> render_click()
html = render(view)
# Member should appear in list (no flash message)
assert html =~ "David"
end
end
end

View file

@ -0,0 +1,460 @@
defmodule MvWeb.GroupLive.ShowAddMemberTest do
@moduledoc """
Tests for adding members to groups via the inline Add Member combobox.
Tests successful add, error handling, and edge cases.
"""
use MvWeb.ConnCase, async: false
import Phoenix.LiveViewTest
import MvWeb.GroupLiveHelpers
use Gettext, backend: MvWeb.Gettext
alias Mv.Membership
alias Mv.Fixtures
describe "successful add member" do
test "member is added to group after selection and clicking Add", %{conn: conn} do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
group = Fixtures.group_fixture()
{:ok, member} =
Membership.create_member(
%{
first_name: "Alice",
last_name: "Johnson",
email: "alice@example.com"
},
actor: system_actor
)
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
open_add_member(view)
search_member(view, "Alice")
select_member(view, member)
add_selected(view)
html = render(view)
assert html =~ "Alice"
assert html =~ "Johnson"
end
test "member is successfully added to group (verified in list)", %{conn: conn} do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
group = Fixtures.group_fixture()
{:ok, member} =
Membership.create_member(
%{
first_name: "Bob",
last_name: "Smith",
email: "bob@example.com"
},
actor: system_actor
)
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Open inline input and add member
view
|> element("button", "Add Member")
|> render_click()
view
|> element("#member-search-input")
|> render_focus()
# phx-change is on the form, so we need to trigger it via the form
view
|> element("form[phx-change='search_members']")
|> render_change(%{"member_search" => "Bob"})
view
|> element("[data-member-id='#{member.id}']")
|> render_click()
view
|> element("button[phx-click='add_selected_members']")
|> render_click()
html = render(view)
# Verify member appears in group list (no success flash message)
assert html =~ "Bob"
assert html =~ "Smith"
end
test "group member list updates automatically after add", %{conn: conn} do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
group = Fixtures.group_fixture()
{:ok, member} =
Membership.create_member(
%{
first_name: "Charlie",
last_name: "Brown",
email: "charlie@example.com"
},
actor: system_actor
)
{:ok, view, html} = live(conn, "/groups/#{group.slug}")
# Initially member should NOT be in list
refute html =~ "Charlie"
# Add member
view
|> element("button", "Add Member")
|> render_click()
view
|> element("#member-search-input")
|> render_focus()
# phx-change is on the form, so we need to trigger it via the form
view
|> element("form[phx-change='search_members']")
|> render_change(%{"member_search" => "Charlie"})
view
|> element("[data-member-id='#{member.id}']")
|> render_click()
view
|> element("button[phx-click='add_selected_members']")
|> render_click()
# Member should now appear in list
html = render(view)
assert html =~ "Charlie"
assert html =~ "Brown"
end
test "member count updates automatically after add", %{conn: conn} do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
group = Fixtures.group_fixture()
{:ok, member} =
Membership.create_member(
%{
first_name: "David",
last_name: "Wilson",
email: "david@example.com"
},
actor: system_actor
)
{:ok, view, html} = live(conn, "/groups/#{group.slug}")
# Get initial count (should be 0)
initial_count = extract_member_count(html)
# Add member
view
|> element("button", "Add Member")
|> render_click()
view
|> element("#member-search-input")
|> render_focus()
# phx-change is on the form, so we need to trigger it via the form
view
|> element("form[phx-change='search_members']")
|> render_change(%{"member_search" => "David"})
view
|> element("[data-member-id='#{member.id}']")
|> render_click()
view
|> element("button[phx-click='add_selected_members']")
|> render_click()
# Count should have increased
html = render(view)
new_count = extract_member_count(html)
assert new_count == initial_count + 1
end
test "inline add member area closes after successful member addition", %{conn: conn} do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
group = Fixtures.group_fixture()
{:ok, member} =
Membership.create_member(
%{
first_name: "Eve",
last_name: "Davis",
email: "eve@example.com"
},
actor: system_actor
)
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Open inline input
view
|> element("button", "Add Member")
|> render_click()
assert has_element?(view, "#member-search-input")
# Add member
view
|> element("#member-search-input")
|> render_focus()
# phx-change is on the form, so we need to trigger it via the form
view
|> element("form[phx-change='search_members']")
|> render_change(%{"member_search" => "Eve"})
view
|> element("[data-member-id='#{member.id}']")
|> render_click()
view
|> element("button[phx-click='add_selected_members']")
|> render_click()
# Inline input should be closed (Add Member button should be visible again)
refute has_element?(view, "#member-search-input")
end
test "Cancel button closes inline add member area without adding", %{conn: conn} do
group = Fixtures.group_fixture()
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
open_add_member(view)
assert has_element?(view, "#member-search-input")
assert has_element?(view, "button[phx-click='hide_add_member_input']")
cancel_add_member(view)
refute has_element?(view, "#member-search-input")
assert has_element?(view, "button", "Add Member")
end
end
describe "error handling" do
test "error flash message when member is already in group", %{conn: conn} do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
group = Fixtures.group_fixture()
{:ok, member} =
Membership.create_member(
%{
first_name: "Frank",
last_name: "Moore",
email: "frank@example.com"
},
actor: system_actor
)
# Add member to group first
Membership.create_member_group(%{member_id: member.id, group_id: group.id},
actor: system_actor
)
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Try to add same member again
view
|> element("button", "Add Member")
|> render_click()
# Member should not appear in search (filtered out)
# But if they do appear somehow, try to add them
# phx-change is on the form, so we need to trigger it via the form
view
|> element("form[phx-change='search_members']")
|> render_change(%{"member_search" => "Frank"})
# If member appears in results (shouldn't), try to add
# This tests the server-side duplicate prevention
if has_element?(view, "[data-member-id='#{member.id}']") do
view
|> element("[data-member-id='#{member.id}']")
|> render_click()
view
|> element("button", "Add")
|> render_click()
# Should show error
html = render(view)
assert html =~ gettext("already in group") || html =~ ~r/already.*group|duplicate/i
end
end
test "error flash message for other errors", %{conn: conn} do
# This test verifies that error handling works for unexpected errors
# We can't easily simulate all error cases, but we test the error path exists
_system_actor = Mv.Helpers.SystemActor.get_system_actor()
group = Fixtures.group_fixture()
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Open inline input
view
|> element("button", "Add Member")
|> render_click()
# Try to add with invalid member ID (if possible)
# This tests error handling path
# Note: Actual implementation will handle this
end
test "inline input remains open on error (user can correct)", %{conn: conn} do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
group = Fixtures.group_fixture()
{:ok, member} =
Membership.create_member(
%{
first_name: "Grace",
last_name: "Taylor",
email: "grace@example.com"
},
actor: system_actor
)
# Add member first
Membership.create_member_group(%{member_id: member.id, group_id: group.id},
actor: system_actor
)
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Open inline input
view
|> element("button", "Add Member")
|> render_click()
# Inline input should be open
assert has_element?(view, "#member-search-input")
# If error occurs, inline input should remain open
# (Implementation will handle this)
end
test "Add button remains disabled until member selected", %{conn: conn} do
group = Fixtures.group_fixture()
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Open inline input
view
|> element("button", "Add Member")
|> render_click()
# Add button should be disabled
assert has_element?(view, "button[phx-click='add_selected_members'][disabled]")
end
end
describe "edge cases" do
test "add works for group with 0 members", %{conn: conn} do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
group = Fixtures.group_fixture()
{:ok, member} =
Membership.create_member(
%{
first_name: "Henry",
last_name: "Anderson",
email: "henry@example.com"
},
actor: system_actor
)
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Add member to empty group
view
|> element("button", "Add Member")
|> render_click()
view
|> element("#member-search-input")
|> render_focus()
# phx-change is on the form, so we need to trigger it via the form
view
|> element("form[phx-change='search_members']")
|> render_change(%{"member_search" => "Henry"})
view
|> element("[data-member-id='#{member.id}']")
|> render_click()
view
|> element("button[phx-click='add_selected_members']")
|> render_click()
# Member should be added
html = render(view)
assert html =~ "Henry"
end
test "add works when member is already in other groups", %{conn: conn} do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
group1 = Fixtures.group_fixture()
group2 = Fixtures.group_fixture()
{:ok, member} =
Membership.create_member(
%{
first_name: "Isabel",
last_name: "Martinez",
email: "isabel@example.com"
},
actor: system_actor
)
# Add member to group1
Membership.create_member_group(%{member_id: member.id, group_id: group1.id},
actor: system_actor
)
{:ok, view, _html} = live(conn, "/groups/#{group2.slug}")
# Add same member to group2 (should work)
view
|> element("button", "Add Member")
|> render_click()
view
|> element("#member-search-input")
|> render_focus()
# phx-change is on the form, so we need to trigger it via the form
view
|> element("form[phx-change='search_members']")
|> render_change(%{"member_search" => "Isabel"})
view
|> element("[data-member-id='#{member.id}']")
|> render_click()
view
|> element("button[phx-click='add_selected_members']")
|> render_click()
# Member should be added to group2
html = render(view)
assert html =~ "Isabel"
end
end
# Helper function to extract member count from HTML
defp extract_member_count(html) do
case Regex.run(~r/Total:\s*(\d+)/, html) do
[_, count_str] -> String.to_integer(count_str)
_ -> 0
end
end
end

View file

@ -0,0 +1,135 @@
defmodule MvWeb.GroupLive.ShowAddRemoveMembersTest do
@moduledoc """
UI tests for Add/Remove Member buttons visibility and inline add member display.
Tests UI rendering and permission-based visibility.
"""
use MvWeb.ConnCase, async: false
import Phoenix.LiveViewTest
use Gettext, backend: MvWeb.Gettext
alias Mv.Membership
alias Mv.Fixtures
describe "Add Member button visibility" do
@tag role: :read_only
test "read_only user can access group show page (page permission)", %{conn: conn} do
group = Fixtures.group_fixture()
conn = get(conn, "/groups/#{group.slug}")
assert conn.status == 200
end
test "Add Member button is visible for users with :update permission", %{conn: conn} do
group = Fixtures.group_fixture()
{:ok, _view, html} = live(conn, "/groups/#{group.slug}")
assert html =~ gettext("Add Member") or html =~ "Add Member"
end
@tag role: :read_only
test "Add Member button is NOT visible for users without :update permission", %{conn: conn} do
group = Fixtures.group_fixture()
{:ok, _view, html} = live(conn, "/groups/#{group.slug}")
refute html =~ gettext("Add Member")
end
test "Add Member button is positioned above member table", %{conn: conn} do
group = Fixtures.group_fixture()
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Button should exist
assert has_element?(view, "button", gettext("Add Member")) ||
has_element?(view, "a", gettext("Add Member"))
end
end
describe "Remove button visibility" do
test "Remove button is visible for each member for users with :update permission", %{
conn: conn
} do
group = Fixtures.group_fixture()
member = Fixtures.member_fixture(%{first_name: "Alice", last_name: "Smith"})
system_actor = Mv.Helpers.SystemActor.get_system_actor()
Membership.create_member_group(%{member_id: member.id, group_id: group.id},
actor: system_actor
)
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Remove button should exist (can be icon button with trash icon)
html = render(view)
assert html =~ "Remove" or html =~ "remove" or html =~ "trash" or
html =~ ~r/hero-trash|hero-x-mark/
end
@tag role: :read_only
test "Remove button is NOT visible for users without :update permission", %{conn: conn} do
group = Fixtures.group_fixture()
member = Fixtures.member_fixture(%{first_name: "Bob", last_name: "Jones"})
system_actor = Mv.Helpers.SystemActor.get_system_actor()
Membership.create_member_group(%{member_id: member.id, group_id: group.id},
actor: system_actor
)
{:ok, _view, html} = live(conn, "/groups/#{group.slug}")
# Remove button should NOT exist (check for trash icon or remove button specifically)
refute html =~ "hero-trash" or html =~ ~r/<button[^>]*remove_member/
end
end
describe "inline add member input" do
test "inline input appears when Add Member button is clicked", %{conn: conn} do
group = Fixtures.group_fixture()
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Click Add Member button
view
|> element("button", gettext("Add Member"))
|> render_click()
# Inline input should be visible
assert has_element?(view, "#member-search-input")
end
test "search input has correct placeholder", %{conn: conn} do
group = Fixtures.group_fixture()
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Open inline input
view
|> element("button", gettext("Add Member"))
|> render_click()
html = render(view)
assert html =~ gettext("Search for a member...") ||
html =~ ~r/search.*member/i
end
test "Add button (plus icon) is disabled until member selected", %{conn: conn} do
group = Fixtures.group_fixture()
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Open inline input
view
|> element("button", gettext("Add Member"))
|> render_click()
html = render(view)
# Add button should exist and be disabled initially
assert has_element?(view, "button[phx-click='add_selected_members'][disabled]") ||
html =~ ~r/disabled/
end
end
end

View file

@ -0,0 +1,285 @@
defmodule MvWeb.GroupLive.ShowAuthorizationTest do
@moduledoc """
Tests for authorization and security in Add/Remove Member functionality.
Tests server-side authorization checks and UI permission enforcement.
"""
use MvWeb.ConnCase, async: false
import Phoenix.LiveViewTest
use Gettext, backend: MvWeb.Gettext
alias Mv.Membership
alias Mv.Fixtures
describe "server-side authorization" do
test "add member event handler checks :update permission", %{conn: conn} do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
group = Fixtures.group_fixture()
{:ok, member} =
Membership.create_member(
%{
first_name: "Alice",
last_name: "Smith",
email: "alice@example.com"
},
actor: system_actor
)
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Open inline input and try to add member
view
|> element("button", "Add Member")
|> render_click()
view
|> element("#member-search-input")
|> render_focus()
# phx-change is on the form, so we need to trigger it via the form
view
|> element("form[phx-change='search_members']")
|> render_change(%{"member_search" => "Alice"})
view
|> element("[data-member-id='#{member.id}']")
|> render_click()
# Try to add (should succeed for admin)
view
|> element("button[phx-click='add_selected_members']")
|> render_click()
# Should succeed (admin has :update permission, member should appear in list)
html = render(view)
assert html =~ "Alice"
end
@tag role: :read_only
test "unauthorized user cannot add member (server-side check)", %{conn: conn} do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
group = Fixtures.group_fixture()
{:ok, _member} =
Membership.create_member(
%{
first_name: "Bob",
last_name: "Jones",
email: "bob@example.com"
},
actor: system_actor
)
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Try to trigger add event directly (even if button is hidden)
# This tests server-side authorization
# Note: If button is hidden, we can't click it, but we test the event handler
# by trying to send the event directly if possible
# For now, we verify that the button is not visible
html = render(view)
refute html =~ "Add Member"
end
test "remove member event handler checks :update permission", %{conn: conn} do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
group = Fixtures.group_fixture()
{:ok, member} =
Membership.create_member(
%{
first_name: "Charlie",
last_name: "Brown",
email: "charlie@example.com"
},
actor: system_actor
)
Membership.create_member_group(%{member_id: member.id, group_id: group.id},
actor: system_actor
)
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Remove member (should succeed for admin)
view
|> element("button[phx-click='remove_member'][phx-value-member_id='#{member.id}']")
|> render_click()
# Should succeed (member should no longer be in list)
html = render(view)
refute html =~ "Charlie"
end
@tag role: :read_only
test "unauthorized user cannot remove member (server-side check)", %{conn: conn} do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
group = Fixtures.group_fixture()
{:ok, member} =
Membership.create_member(
%{
first_name: "David",
last_name: "Wilson",
email: "david@example.com"
},
actor: system_actor
)
Membership.create_member_group(%{member_id: member.id, group_id: group.id},
actor: system_actor
)
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Remove button should not be visible
html = render(view)
# Read-only user should NOT see Remove button (check for trash icon or remove button specifically)
refute html =~ "hero-trash" or html =~ ~r/<button[^>]*remove_member/
end
test "error flash message on unauthorized access", %{conn: conn} do
# This test verifies that error messages are shown for unauthorized access
# Implementation will handle this in event handlers
_system_actor = Mv.Helpers.SystemActor.get_system_actor()
group = Fixtures.group_fixture()
{:ok, _view, _html} = live(conn, "/groups/#{group.slug}")
# For admin, should not see error
# For non-admin, buttons are hidden (UI-level check)
# Server-side check will show error if event is somehow triggered
end
end
describe "UI permission checks" do
test "buttons are hidden for unauthorized users", %{conn: conn} do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
group = Fixtures.group_fixture()
{:ok, member} =
Membership.create_member(
%{
first_name: "Eve",
last_name: "Davis",
email: "eve@example.com"
},
actor: system_actor
)
Membership.create_member_group(%{member_id: member.id, group_id: group.id},
actor: system_actor
)
{:ok, _view, html} = live(conn, "/groups/#{group.slug}")
# Admin should see buttons
assert html =~ "Add Member" || html =~ "Remove"
end
@tag role: :read_only
test "Add Member button is hidden for read-only users", %{conn: conn} do
_system_actor = Mv.Helpers.SystemActor.get_system_actor()
group = Fixtures.group_fixture()
{:ok, _view, html} = live(conn, "/groups/#{group.slug}")
# Read-only user should NOT see Add Member button
refute html =~ "Add Member"
end
@tag role: :read_only
test "Remove button is hidden for read-only users", %{conn: conn} do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
group = Fixtures.group_fixture()
{:ok, member} =
Membership.create_member(
%{
first_name: "Frank",
last_name: "Moore",
email: "frank@example.com"
},
actor: system_actor
)
Membership.create_member_group(%{member_id: member.id, group_id: group.id},
actor: system_actor
)
{:ok, _view, html} = live(conn, "/groups/#{group.slug}")
# Read-only user should NOT see Remove button (check for trash icon or remove button specifically)
refute html =~ "hero-trash" or html =~ ~r/<button[^>]*remove_member/
end
@tag role: :read_only
test "inline add member area cannot be opened for unauthorized users", %{conn: conn} do
group = Fixtures.group_fixture()
{:ok, _view, html} = live(conn, "/groups/#{group.slug}")
# Inline input should not be accessible (button hidden)
refute html =~ "Add Member"
refute html =~ "#member-search-input"
end
end
describe "member (own_data) page access" do
# Members have no page permission for /groups or /groups/:slug; they are redirected.
# This tests that limited access for the member role is enforced.
@tag role: :member
test "member is redirected when accessing group show page", %{conn: conn} do
group = Fixtures.group_fixture()
result = live(conn, "/groups/#{group.slug}")
assert {:error, {:redirect, %{to: path, flash: %{"error" => _}}}} = result
assert path =~ ~r|^/users/[^/]+$|
end
@tag role: :member
test "member is redirected when accessing groups index", %{conn: conn} do
result = live(conn, "/groups")
assert {:error, {:redirect, %{to: path, flash: %{"error" => _}}}} = result
assert path =~ ~r|^/users/[^/]+$|
end
end
describe "security edge cases" do
test "slug injection attempts are prevented", %{conn: conn} do
# Try to inject malicious content in slug
malicious_slug = "'; DROP TABLE groups; --"
result = live(conn, "/groups/#{malicious_slug}")
# Should not execute SQL, should return 404 or error
assert match?({:error, {:redirect, %{to: "/groups"}}}, result) ||
match?({:error, {:live_redirect, %{to: "/groups"}}}, result)
end
@tag :skip
test "non-existent member IDs are handled", %{conn: conn} do
# Future: test add_selected_members with invalid ID (would require pushing event with forged selected_member_ids)
group = Fixtures.group_fixture()
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
assert has_element?(view, "button", "Add Member")
end
test "non-existent group IDs are handled", %{conn: conn} do
# Accessing non-existent group should redirect
non_existent_slug = "non-existent-group-#{System.unique_integer([:positive])}"
result = live(conn, "/groups/#{non_existent_slug}")
assert match?({:error, {:redirect, %{to: "/groups"}}}, result) ||
match?({:error, {:live_redirect, %{to: "/groups"}}}, result)
end
end
end

View file

@ -0,0 +1,432 @@
defmodule MvWeb.GroupLive.ShowIntegrationTest do
@moduledoc """
Integration tests for Add/Remove Member functionality.
Tests data consistency, database operations, and multiple operations.
"""
use MvWeb.ConnCase, async: false
import Phoenix.LiveViewTest
use Gettext, backend: MvWeb.Gettext
alias Mv.Membership
alias Mv.Fixtures
describe "data consistency" do
test "member appears in group after add (verified in database)", %{conn: conn} do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
group = Fixtures.group_fixture()
{:ok, member} =
Membership.create_member(
%{
first_name: "Alice",
last_name: "Smith",
email: "alice@example.com"
},
actor: system_actor
)
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Add member via UI
view
|> element("button", "Add Member")
|> render_click()
view
|> element("#member-search-input")
|> render_focus()
# phx-change is on the form, so we need to trigger it via the form
view
|> element("form[phx-change='search_members']")
|> render_change(%{"member_search" => "Alice"})
view
|> element("[data-member-id='#{member.id}']")
|> render_click()
view
|> element("button[phx-click='add_selected_members']")
|> render_click()
# Verify in database
require Ash.Query
query =
Mv.Membership.Group
|> Ash.Query.filter(slug == ^group.slug)
|> Ash.Query.load([:members])
{:ok, updated_group} = Ash.read_one(query, actor: system_actor, domain: Mv.Membership)
# Member should be in group
assert Enum.any?(updated_group.members, &(&1.id == member.id))
end
test "member disappears from group after remove (verified in database)", %{conn: conn} do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
group = Fixtures.group_fixture()
{:ok, member} =
Membership.create_member(
%{
first_name: "Bob",
last_name: "Jones",
email: "bob@example.com"
},
actor: system_actor
)
# Add member to group
Membership.create_member_group(%{member_id: member.id, group_id: group.id},
actor: system_actor
)
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Remove member via UI
view
|> element("button[phx-click='remove_member'][phx-value-member_id='#{member.id}']")
|> render_click()
# Verify in database
require Ash.Query
query =
Mv.Membership.Group
|> Ash.Query.filter(slug == ^group.slug)
|> Ash.Query.load([:members])
{:ok, updated_group} = Ash.read_one(query, actor: system_actor, domain: Mv.Membership)
# Member should NOT be in group
refute Enum.any?(updated_group.members, &(&1.id == member.id))
end
test "MemberGroup association is created correctly", %{conn: conn} do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
group = Fixtures.group_fixture()
{:ok, member} =
Membership.create_member(
%{
first_name: "Charlie",
last_name: "Brown",
email: "charlie@example.com"
},
actor: system_actor
)
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Add member
view
|> element("button", "Add Member")
|> render_click()
view
|> element("#member-search-input")
|> render_focus()
# phx-change is on the form, so we need to trigger it via the form
view
|> element("form[phx-change='search_members']")
|> render_change(%{"member_search" => "Charlie"})
view
|> element("[data-member-id='#{member.id}']")
|> render_click()
view
|> element("button[phx-click='add_selected_members']")
|> render_click()
# Verify MemberGroup association exists
require Ash.Query
{:ok, member_groups} =
Ash.read(
Mv.Membership.MemberGroup
|> Ash.Query.filter(member_id == ^member.id and group_id == ^group.id),
actor: system_actor,
domain: Mv.Membership
)
assert length(member_groups) == 1
assert hd(member_groups).member_id == member.id
assert hd(member_groups).group_id == group.id
end
test "MemberGroup association is deleted correctly", %{conn: conn} do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
group = Fixtures.group_fixture()
{:ok, member} =
Membership.create_member(
%{
first_name: "David",
last_name: "Wilson",
email: "david@example.com"
},
actor: system_actor
)
# Add member first
Membership.create_member_group(%{member_id: member.id, group_id: group.id},
actor: system_actor
)
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Remove member
view
|> element("button[phx-click='remove_member'][phx-value-member_id='#{member.id}']")
|> render_click()
# Verify MemberGroup association is deleted
require Ash.Query
{:ok, member_groups} =
Ash.read(
Mv.Membership.MemberGroup
|> Ash.Query.filter(member_id == ^member.id and group_id == ^group.id),
actor: system_actor,
domain: Mv.Membership
)
assert member_groups == []
end
test "member itself is NOT deleted (only association)", %{conn: conn} do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
group = Fixtures.group_fixture()
{:ok, member} =
Membership.create_member(
%{
first_name: "Eve",
last_name: "Davis",
email: "eve@example.com"
},
actor: system_actor
)
# Add member to group
Membership.create_member_group(%{member_id: member.id, group_id: group.id},
actor: system_actor
)
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Remove member from group
view
|> element("button[phx-click='remove_member']", "")
|> render_click()
# Verify member still exists
{:ok, member_after_remove} =
Ash.get(Mv.Membership.Member, member.id, actor: system_actor)
assert member_after_remove.id == member.id
assert member_after_remove.first_name == "Eve"
end
end
describe "multiple operations" do
test "multiple members can be added sequentially", %{conn: conn} do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
group = Fixtures.group_fixture()
{:ok, member1} =
Membership.create_member(
%{
first_name: "Frank",
last_name: "Moore",
email: "frank@example.com"
},
actor: system_actor
)
{:ok, member2} =
Membership.create_member(
%{
first_name: "Grace",
last_name: "Taylor",
email: "grace@example.com"
},
actor: system_actor
)
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Add first member
view
|> element("button", "Add Member")
|> render_click()
view
|> element("#member-search-input")
|> render_focus()
# phx-change is on the form, so we need to trigger it via the form
view
|> element("form[phx-change='search_members']")
|> render_change(%{"member_search" => "Frank"})
view
|> element("[data-member-id='#{member1.id}']")
|> render_click()
view
|> element("button[phx-click='add_selected_members']")
|> render_click()
# Add second member
view
|> element("button", "Add Member")
|> render_click()
view
|> element("#member-search-input")
|> render_focus()
# phx-change is on the form, so we need to trigger it via the form
view
|> element("form[phx-change='search_members']")
|> render_change(%{"member_search" => "Grace"})
view
|> element("[data-member-id='#{member2.id}']")
|> render_click()
view
|> element("button[phx-click='add_selected_members']")
|> render_click()
# Both members should be in list
html = render(view)
assert html =~ "Frank"
assert html =~ "Grace"
end
test "multiple members can be removed sequentially", %{conn: conn} do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
group = Fixtures.group_fixture()
{:ok, member1} =
Membership.create_member(
%{
first_name: "Henry",
last_name: "Anderson",
email: "henry@example.com"
},
actor: system_actor
)
{:ok, member2} =
Membership.create_member(
%{
first_name: "Isabel",
last_name: "Martinez",
email: "isabel@example.com"
},
actor: system_actor
)
# Add both members
Membership.create_member_group(%{member_id: member1.id, group_id: group.id},
actor: system_actor
)
Membership.create_member_group(%{member_id: member2.id, group_id: group.id},
actor: system_actor
)
{:ok, view, html} = live(conn, "/groups/#{group.slug}")
# Both should be in list initially
assert html =~ "Henry"
assert html =~ "Isabel"
# Remove first member
view
|> element("button[phx-click='remove_member'][phx-value-member_id='#{member1.id}']")
|> render_click()
# Remove second member
view
|> element("button[phx-click='remove_member'][phx-value-member_id='#{member2.id}']")
|> render_click()
# Both should be removed
html = render(view)
refute html =~ "Henry"
refute html =~ "Isabel"
end
test "add and remove can be mixed", %{conn: conn} do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
group = Fixtures.group_fixture()
{:ok, member1} =
Membership.create_member(
%{
first_name: "Jack",
last_name: "White",
email: "jack@example.com"
},
actor: system_actor
)
{:ok, member2} =
Membership.create_member(
%{
first_name: "Kate",
last_name: "Black",
email: "kate@example.com"
},
actor: system_actor
)
# Add member1 first
Membership.create_member_group(%{member_id: member1.id, group_id: group.id},
actor: system_actor
)
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Add member2
view
|> element("button", "Add Member")
|> render_click()
view
|> element("#member-search-input")
|> render_focus()
# phx-change is on the form, so we need to trigger it via the form
view
|> element("form[phx-change='search_members']")
|> render_change(%{"member_search" => "Kate"})
view
|> element("[data-member-id='#{member2.id}']")
|> render_click()
view
|> element("button[phx-click='add_selected_members']")
|> render_click()
# Remove member1
view
|> element("button[phx-click='remove_member'][phx-value-member_id='#{member1.id}']")
|> render_click()
# Only member2 should remain
html = render(view)
refute html =~ "Jack"
assert html =~ "Kate"
end
end
end

View file

@ -0,0 +1,339 @@
defmodule MvWeb.GroupLive.ShowMemberSearchTest do
@moduledoc """
UI tests for member search functionality in inline Add Member combobox.
Tests search behavior and filtering of members already in group.
"""
use MvWeb.ConnCase, async: false
import Phoenix.LiveViewTest
use Gettext, backend: MvWeb.Gettext
alias Mv.Membership
alias Mv.Fixtures
# Helper to setup authenticated connection for admin
defp setup_admin_conn(conn) do
conn_with_oidc_user(conn, %{email: "admin@example.com"})
end
describe "search functionality" do
test "search finds member by exact name", %{conn: conn} do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
conn = setup_admin_conn(conn)
group = Fixtures.group_fixture()
{:ok, _member} =
Membership.create_member(
%{
first_name: "Jonathan",
last_name: "Smith",
email: "jonathan.smith@example.com"
},
actor: system_actor
)
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Open inline input
view
|> element("button", "Add Member")
|> render_click()
# Type exact name
# phx-change is on the form, so we need to trigger it via the form
view
|> element("form[phx-change='search_members']")
|> render_change(%{"member_search" => "Jonathan"})
html = render(view)
assert html =~ "Jonathan"
assert html =~ "Smith"
end
test "search finds member by partial name (fuzzy)", %{conn: conn} do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
conn = setup_admin_conn(conn)
group = Fixtures.group_fixture()
{:ok, _member} =
Membership.create_member(
%{
first_name: "Jonathan",
last_name: "Smith",
email: "jonathan.smith@example.com"
},
actor: system_actor
)
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Open inline input
view
|> element("button", "Add Member")
|> render_click()
# Type partial name
# phx-change is on the form, so we need to trigger it via the form
view
|> element("form[phx-change='search_members']")
|> render_change(%{"member_search" => "Jon"})
html = render(view)
# Fuzzy search should find Jonathan
assert html =~ "Jonathan"
assert html =~ "Smith"
end
test "search finds member by email", %{conn: conn} do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
conn = setup_admin_conn(conn)
group = Fixtures.group_fixture()
{:ok, _member} =
Membership.create_member(
%{
first_name: "Alice",
last_name: "Johnson",
email: "alice.johnson@example.com"
},
actor: system_actor
)
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Open inline input
view
|> element("button", "Add Member")
|> render_click()
# Search by email
# phx-change is on the form, so we need to trigger it via the form
view
|> element("form[phx-change='search_members']")
|> render_change(%{"member_search" => "alice.johnson"})
html = render(view)
assert html =~ "Alice"
assert html =~ "Johnson"
assert html =~ "alice.johnson@example.com"
end
test "dropdown shows member name and email", %{conn: conn} do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
conn = setup_admin_conn(conn)
group = Fixtures.group_fixture()
{:ok, _member} =
Membership.create_member(
%{
first_name: "Bob",
last_name: "Williams",
email: "bob@example.com"
},
actor: system_actor
)
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Open inline input
view
|> element("button", "Add Member")
|> render_click()
# Focus and search
view
|> element("#member-search-input")
|> render_focus()
# phx-change is on the form, so we need to trigger it via the form
view
|> element("form[phx-change='search_members']")
|> render_change(%{"member_search" => "Bob"})
html = render(view)
assert html =~ "Bob"
assert html =~ "Williams"
assert html =~ "bob@example.com"
end
test "ComboBox hook works (focus opens dropdown)", %{conn: conn} do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
conn = setup_admin_conn(conn)
group = Fixtures.group_fixture()
{:ok, _member} =
Membership.create_member(
%{
first_name: "Charlie",
last_name: "Brown",
email: "charlie@example.com"
},
actor: system_actor
)
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Open inline input
view
|> element("button", "Add Member")
|> render_click()
# Focus input
view
|> element("#member-search-input")
|> render_focus()
html = render(view)
# Dropdown should be visible
assert html =~ ~r/role="listbox"/ || html =~ "listbox"
end
end
describe "filtering members already in group" do
test "members already in group are NOT shown in search results", %{conn: conn} do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
conn = setup_admin_conn(conn)
group = Fixtures.group_fixture()
# Create member and add to group
{:ok, member_in_group} =
Membership.create_member(
%{
first_name: "David",
last_name: "Miller",
email: "david@example.com"
},
actor: system_actor
)
Membership.create_member_group(%{member_id: member_in_group.id, group_id: group.id},
actor: system_actor
)
# Create another member NOT in group
{:ok, _member_not_in_group} =
Membership.create_member(
%{
first_name: "David",
last_name: "Anderson",
email: "david.anderson@example.com"
},
actor: system_actor
)
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Open inline input
view
|> element("button", "Add Member")
|> render_click()
# Search for "David"
# phx-change is on the form, so we need to trigger it via the form
view
|> element("form[phx-change='search_members']")
|> render_change(%{"member_search" => "David"})
# Assert only on dropdown (available members), not the members table
dropdown_html = view |> element("#member-dropdown") |> render()
assert dropdown_html =~ "Anderson"
refute dropdown_html =~ "Miller"
end
test "search filters correctly when group has many members", %{conn: conn} do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
conn = setup_admin_conn(conn)
group = Fixtures.group_fixture()
# Add multiple members to group
Enum.each(1..5, fn i ->
{:ok, m} =
Membership.create_member(
%{
first_name: "Member#{i}",
last_name: "InGroup",
email: "member#{i}@example.com"
},
actor: system_actor
)
Membership.create_member_group(%{member_id: m.id, group_id: group.id},
actor: system_actor
)
end)
# Create member NOT in group
{:ok, _member_not_in_group} =
Membership.create_member(
%{
first_name: "Available",
last_name: "Member",
email: "available@example.com"
},
actor: system_actor
)
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Open inline input
view
|> element("button", "Add Member")
|> render_click()
# Search
# phx-change is on the form, so we need to trigger it via the form
view
|> element("form[phx-change='search_members']")
|> render_change(%{"member_search" => "Available"})
# Assert only on dropdown (available members), not the members table
dropdown_html = view |> element("#member-dropdown") |> render()
assert dropdown_html =~ "Available"
assert dropdown_html =~ "Member"
refute dropdown_html =~ "Member1"
refute dropdown_html =~ "Member2"
end
test "search shows no results when all available members are already in group", %{conn: conn} do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
conn = setup_admin_conn(conn)
group = Fixtures.group_fixture()
# Create and add all members to group
{:ok, member} =
Membership.create_member(
%{
first_name: "Only",
last_name: "Member",
email: "only@example.com"
},
actor: system_actor
)
Membership.create_member_group(%{member_id: member.id, group_id: group.id},
actor: system_actor
)
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Open inline input
view
|> element("button", "Add Member")
|> render_click()
# Search
# phx-change is on the form, so we need to trigger it via the form
view
|> element("form[phx-change='search_members']")
|> render_change(%{"member_search" => "Only"})
# When no available members, dropdown is not rendered (length(@available_members) == 0)
refute has_element?(view, "#member-dropdown")
end
end
end

View file

@ -0,0 +1,334 @@
defmodule MvWeb.GroupLive.ShowRemoveMemberTest do
@moduledoc """
Tests for removing members from groups via the Remove button.
Tests successful remove, edge cases, and immediate removal (no confirmation).
"""
use MvWeb.ConnCase, async: false
import Phoenix.LiveViewTest
use Gettext, backend: MvWeb.Gettext
alias Mv.Membership
alias Mv.Fixtures
describe "successful remove member" do
test "member is removed from group after clicking Remove", %{conn: conn} do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
group = Fixtures.group_fixture()
{:ok, member} =
Membership.create_member(
%{
first_name: "Alice",
last_name: "Smith",
email: "alice@example.com"
},
actor: system_actor
)
# Add member to group
Membership.create_member_group(%{member_id: member.id, group_id: group.id},
actor: system_actor
)
{:ok, view, html} = live(conn, "/groups/#{group.slug}")
# Member should be in list initially
assert html =~ "Alice"
# Click Remove button
view
|> element("button[phx-click='remove_member'][phx-value-member_id='#{member.id}']")
|> render_click()
# Member should no longer be in list (no success flash message)
html = render(view)
refute html =~ "Alice"
end
test "member is successfully removed from group (verified in list)", %{conn: conn} do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
group = Fixtures.group_fixture()
{:ok, member} =
Membership.create_member(
%{
first_name: "Bob",
last_name: "Jones",
email: "bob@example.com"
},
actor: system_actor
)
Membership.create_member_group(%{member_id: member.id, group_id: group.id},
actor: system_actor
)
{:ok, view, html} = live(conn, "/groups/#{group.slug}")
# Member should be in list initially
assert html =~ "Bob"
# Remove member
view
|> element("button[phx-click='remove_member'][phx-value-member_id='#{member.id}']")
|> render_click()
html = render(view)
# Member should no longer be in list (no success flash message)
refute html =~ "Bob"
end
test "group member list updates automatically after remove", %{conn: conn} do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
group = Fixtures.group_fixture()
{:ok, member} =
Membership.create_member(
%{
first_name: "Charlie",
last_name: "Brown",
email: "charlie@example.com"
},
actor: system_actor
)
Membership.create_member_group(%{member_id: member.id, group_id: group.id},
actor: system_actor
)
{:ok, view, html} = live(conn, "/groups/#{group.slug}")
# Member should be in list initially
assert html =~ "Charlie"
# Remove member
view
|> element("button[phx-click='remove_member'][phx-value-member_id='#{member.id}']")
|> render_click()
# Member should no longer be in list
html = render(view)
refute html =~ "Charlie"
end
test "member count updates automatically after remove", %{conn: conn} do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
group = Fixtures.group_fixture()
{:ok, member1} =
Membership.create_member(
%{
first_name: "David",
last_name: "Wilson",
email: "david@example.com"
},
actor: system_actor
)
{:ok, member2} =
Membership.create_member(
%{
first_name: "Eve",
last_name: "Davis",
email: "eve@example.com"
},
actor: system_actor
)
# Add both members
Membership.create_member_group(%{member_id: member1.id, group_id: group.id},
actor: system_actor
)
Membership.create_member_group(%{member_id: member2.id, group_id: group.id},
actor: system_actor
)
{:ok, view, html} = live(conn, "/groups/#{group.slug}")
# Get initial count (should be 2)
initial_count = extract_member_count(html)
assert initial_count >= 2
# Remove one member (need to get member_id from HTML or use first available)
# For this test, we'll remove the first member
_html_before = render(view)
# Extract first member ID from the rendered HTML or use a different approach
# Since we have member1 and member2, we can target member1 specifically
view
|> element("button[phx-click='remove_member'][phx-value-member_id='#{member1.id}']")
|> render_click()
# Count should have decreased
html = render(view)
new_count = extract_member_count(html)
assert new_count == initial_count - 1
end
test "no confirmation dialog appears (immediate removal)", %{conn: conn} do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
group = Fixtures.group_fixture()
{:ok, member} =
Membership.create_member(
%{
first_name: "Frank",
last_name: "Moore",
email: "frank@example.com"
},
actor: system_actor
)
Membership.create_member_group(%{member_id: member.id, group_id: group.id},
actor: system_actor
)
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Click Remove - should remove immediately without confirmation
view
|> element("button[phx-click='remove_member'][phx-value-member_id='#{member.id}']")
|> render_click()
# No confirmation dialog should appear (immediate removal)
# This is verified by the member being removed without any dialog
# Member should be removed
html = render(view)
refute html =~ "Frank"
end
end
describe "edge cases" do
test "remove works for last member in group (group becomes empty)", %{conn: conn} do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
group = Fixtures.group_fixture()
{:ok, member} =
Membership.create_member(
%{
first_name: "Grace",
last_name: "Taylor",
email: "grace@example.com"
},
actor: system_actor
)
Membership.create_member_group(%{member_id: member.id, group_id: group.id},
actor: system_actor
)
{:ok, view, html} = live(conn, "/groups/#{group.slug}")
# Member should be in list
assert html =~ "Grace"
# Remove last member
view
|> element("button[phx-click='remove_member'][phx-value-member_id='#{member.id}']")
|> render_click()
# Group should show empty state
html = render(view)
assert html =~ gettext("No members in this group") ||
html =~ ~r/no.*members/i
# Count should be 0
count = extract_member_count(html)
assert count == 0
end
test "remove works when member is in multiple groups (only this group affected)", %{
conn: conn
} do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
group1 = Fixtures.group_fixture()
group2 = Fixtures.group_fixture()
{:ok, member} =
Membership.create_member(
%{
first_name: "Henry",
last_name: "Anderson",
email: "henry@example.com"
},
actor: system_actor
)
# Add member to both groups
Membership.create_member_group(%{member_id: member.id, group_id: group1.id},
actor: system_actor
)
Membership.create_member_group(%{member_id: member.id, group_id: group2.id},
actor: system_actor
)
{:ok, view, _html} = live(conn, "/groups/#{group1.slug}")
# Remove from group1
view
|> element("button[phx-click='remove_member'][phx-value-member_id='#{member.id}']")
|> render_click()
# Member should be removed from group1
html = render(view)
refute html =~ "Henry"
# Verify member is still in group2
{:ok, _view2, html2} = live(conn, "/groups/#{group2.slug}")
assert html2 =~ "Henry"
end
test "remove is idempotent (no error if member already removed)", %{conn: conn} do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
group = Fixtures.group_fixture()
{:ok, member} =
Membership.create_member(
%{
first_name: "Isabel",
last_name: "Martinez",
email: "isabel@example.com"
},
actor: system_actor
)
Membership.create_member_group(%{member_id: member.id, group_id: group.id},
actor: system_actor
)
{:ok, view, _html} = live(conn, "/groups/#{group.slug}")
# Remove member first time
view
|> element("button[phx-click='remove_member'][phx-value-member_id='#{member.id}']")
|> render_click()
# Try to remove again (should not error, just be idempotent)
# Note: Implementation should handle this gracefully
# If button is still visible somehow, try to click again
html = render(view)
if html =~ "Isabel" do
view
|> element("button[phx-click='remove_member'][phx-value-member_id='#{member.id}']")
|> render_click()
# Should not crash
assert render(view)
end
end
end
# Helper function to extract member count from HTML
defp extract_member_count(html) do
case Regex.run(~r/Total:\s*(\d+)/, html) do
[_, count_str] -> String.to_integer(count_str)
_ -> 0
end
end
end

View file

@ -178,6 +178,7 @@ defmodule MvWeb.ConnCase do
:read_only -> :read_only ->
# Vorstand/Buchhaltung: can read members, groups; cannot edit or access admin/settings # Vorstand/Buchhaltung: can read members, groups; cannot edit or access admin/settings
read_only_user = Mv.Fixtures.user_with_role_fixture("read_only") read_only_user = Mv.Fixtures.user_with_role_fixture("read_only")
read_only_user = Mv.Authorization.Actor.ensure_loaded(read_only_user)
authenticated_conn = conn_with_password_user(conn, read_only_user) authenticated_conn = conn_with_password_user(conn, read_only_user)
{authenticated_conn, read_only_user} {authenticated_conn, read_only_user}

View file

@ -0,0 +1,59 @@
defmodule MvWeb.GroupLiveHelpers do
@moduledoc """
Helpers for Group LiveView tests (e.g. group show add/remove member flow).
Use these to reduce duplication in tests that open the add member area,
search, select, and add members.
"""
import Phoenix.LiveViewTest
@doc """
Opens the inline add member area by clicking "Add Member".
"""
def open_add_member(view) do
view
|> element("button", "Add Member")
|> render_click()
end
@doc """
Triggers member search by focusing the input and sending a form change with the given query.
"""
def search_member(view, query) do
view
|> element("#member-search-input")
|> render_focus()
view
|> element("form[phx-change='search_members']")
|> render_change(%{"member_search" => query})
end
@doc """
Clicks the option for the given member in the dropdown (by data-member-id).
"""
def select_member(view, member) do
view
|> element("[data-member-id='#{member.id}']")
|> render_click()
end
@doc """
Clicks the "Add" button (add_selected_members).
"""
def add_selected(view) do
view
|> element("button[phx-click='add_selected_members']")
|> render_click()
end
@doc """
Clicks the "Cancel" button to close the inline add member area.
"""
def cancel_add_member(view) do
view
|> element("button[phx-click='hide_add_member_input']")
|> render_click()
end
end