feat: restyle tabs and move delete to edit view

This commit is contained in:
carla 2026-02-25 10:33:30 +01:00
parent ff9f98f8e7
commit 02af136fd9
8 changed files with 361 additions and 276 deletions

View file

@ -487,7 +487,7 @@ defmodule MvWeb.Components.MemberFilterComponent do
# Get boolean filter label (comma-separated list of active filter names) # Get boolean filter label (comma-separated list of active filter names)
defp boolean_filter_label(_boolean_custom_fields, boolean_filters) defp boolean_filter_label(_boolean_custom_fields, boolean_filters)
when map_size(boolean_filters) == 0 do when map_size(boolean_filters) == 0 do
gettext("All") gettext("Apply filters")
end end
defp boolean_filter_label(boolean_custom_fields, boolean_filters) do defp boolean_filter_label(boolean_custom_fields, boolean_filters) do

View file

@ -6,7 +6,6 @@ defmodule MvWeb.MemberLive.Form do
- Create new members with personal information - Create new members with personal information
- Edit existing member details - Edit existing member details
- Grouped sections for better organization - Grouped sections for better organization
- Tab navigation (Payments tab disabled, coming soon)
- Manage custom properties (dynamic fields, displayed sorted by name) - Manage custom properties (dynamic fields, displayed sorted by name)
- Real-time validation with visual feedback - Real-time validation with visual feedback
@ -56,23 +55,12 @@ defmodule MvWeb.MemberLive.Form do
</.header> </.header>
<div class="mt-6 space-y-6"> <div class="mt-6 space-y-6">
<%!-- Tab Navigation --%> <%!-- Tab navigation: Payments tab not shown on new/edit (only on member show) --%>
<div role="tablist" class="tabs tabs-bordered"> <div role="tablist" class="tabs tabs-bordered">
<button type="button" role="tab" class="tab tab-active" aria-selected="true"> <button type="button" role="tab" class="tab tab-active" aria-selected="true">
<.icon name="hero-identification" class="size-4 mr-2" /> <.icon name="hero-identification" class="size-4 mr-2" />
{gettext("Contact Data")} {gettext("Contact Data")}
</button> </button>
<button
type="button"
role="tab"
class="tab"
disabled
aria-disabled="true"
title={gettext("Coming soon")}
>
<.icon name="hero-credit-card" class="size-4 mr-2" />
{gettext("Payments")}
</button>
</div> </div>
<%!-- Personal Data and Custom Fields Row --%> <%!-- Personal Data and Custom Fields Row --%>

View file

@ -15,7 +15,6 @@ defmodule MvWeb.MemberLive.Index do
- `sort_order` - Sort direction (:asc or :desc) - `sort_order` - Sort direction (:asc or :desc)
## Events ## Events
- `delete` - Remove a member from the database
- `select_member` - Toggle individual member selection - `select_member` - Toggle individual member selection
- `select_all` - Toggle selection of all visible members - `select_all` - Toggle selection of all visible members
- `copy_emails` - Copy email addresses of selected members to clipboard - `copy_emails` - Copy email addresses of selected members to clipboard
@ -157,50 +156,10 @@ defmodule MvWeb.MemberLive.Index do
Handles member-related UI events. Handles member-related UI events.
## Supported events: ## Supported events:
- `"delete"` - Removes a member from the database
- `"select_member"` - Toggles individual member selection - `"select_member"` - Toggles individual member selection
- `"select_all"` - Toggles selection of all visible members - `"select_all"` - Toggles selection of all visible members
- `"sort"` - Sort event from SortHeaderComponent. Updates sort field/order and syncs URL - `"sort"` - Sort event from SortHeaderComponent. Updates sort field/order and syncs URL
""" """
@impl true
def handle_event("delete", %{"id" => id}, socket) do
actor = current_actor(socket)
case Ash.get(Mv.Membership.Member, id, actor: actor) do
{:ok, member} ->
case Ash.destroy(member, actor: actor) do
:ok ->
updated_members = Enum.reject(socket.assigns.members, &(&1.id == id))
{:noreply,
socket
|> assign(:members, updated_members)
|> put_flash(:success, gettext("Member deleted successfully"))}
{:error, %Ash.Error.Forbidden{}} ->
{:noreply,
put_flash(
socket,
:error,
gettext("You do not have permission to delete this member")
)}
{:error, error} ->
{:noreply, put_flash(socket, :error, format_error(error))}
end
{:error, %Ash.Error.Query.NotFound{}} ->
{:noreply, put_flash(socket, :error, gettext("Member not found"))}
{:error, %Ash.Error.Forbidden{} = _error} ->
{:noreply,
put_flash(socket, :error, gettext("You do not have permission to access this member"))}
{:error, error} ->
{:noreply, put_flash(socket, :error, format_error(error))}
end
end
@impl true @impl true
def handle_event("select_member", %{"id" => id}, socket) do def handle_event("select_member", %{"id" => id}, socket) do
selected = selected =
@ -343,22 +302,6 @@ defmodule MvWeb.MemberLive.Index do
{:noreply, push_patch(socket, to: ~p"/members?#{query_params}", replace: true)} {:noreply, push_patch(socket, to: ~p"/members?#{query_params}", replace: true)}
end end
# Helper to format errors for display
defp format_error(%Ash.Error.Invalid{errors: errors}) do
error_messages =
Enum.map(errors, fn error ->
case error do
%{field: field, message: message} -> "#{field}: #{message}"
%{message: message} -> message
_ -> inspect(error)
end
end)
Enum.join(error_messages, ", ")
end
defp format_error(error), do: inspect(error)
# ----------------------------------------------------------------- # -----------------------------------------------------------------
# Handle Infos from Child Components # Handle Infos from Child Components
# ----------------------------------------------------------------- # -----------------------------------------------------------------

View file

@ -379,26 +379,10 @@
</:col> </:col>
<:action :let={member}> <:action :let={member}>
<div class="sr-only"> <div class="sr-only">
<.link navigate={~p"/members/#{member}"}>{gettext("Show")}</.link> <.link navigate={~p"/members/#{member}"} data-testid="member-show-link">
{gettext("Show")}
</.link>
</div> </div>
<%= if can?(@current_user, :update, member) do %>
<.link navigate={~p"/members/#{member}/edit"} data-testid="member-edit">
{gettext("Edit member")}
</.link>
<% end %>
</:action>
<:action :let={member}>
<%= if can?(@current_user, :destroy, member) do %>
<.link
phx-click={JS.push("delete", value: %{id: member.id}) |> hide("#row-#{member.id}")}
data-confirm={gettext("Are you sure?")}
data-testid="member-delete"
>
{gettext("Delete")}
</.link>
<% end %>
</:action> </:action>
</.table> </.table>
</Layouts.app> </Layouts.app>

View file

@ -54,217 +54,283 @@ defmodule MvWeb.MemberLive.Show do
</.header> </.header>
<div class="mt-6 space-y-6"> <div class="mt-6 space-y-6">
<%!-- Tab Navigation --%> <%!-- Tab Navigation: surface only behind buttons (inline); tabs-bordered; tabs-lg; both tabs keyboard-focusable --%>
<div role="tablist" class="tabs tabs-bordered"> <div
role="tablist"
class="tabs tabs-bordered tabs-lg bg-base-200/60 rounded-box p-1 w-fit"
>
<button <button
id="member-tab-contact"
role="tab" role="tab"
class={[ type="button"
"tab", tabindex="0"
if(@active_tab == :contact, do: "tab-active", else: "!text-gray-800")
]}
aria-selected={@active_tab == :contact} aria-selected={@active_tab == :contact}
aria-controls="member-tabpanel-contact"
class={[
"tab flex items-center gap-2",
if(@active_tab == :contact, do: "tab-active", else: "text-base-content/70")
]}
phx-click="switch_tab" phx-click="switch_tab"
phx-value-tab="contact" phx-value-tab="contact"
> >
<.icon name="hero-identification" class="size-4 mr-2" /> <.icon name="hero-identification" class="size-4 shrink-0" />
{gettext("Contact Data")} {gettext("Contact Data")}
</button> </button>
<button <button
id="member-tab-membership_fees"
role="tab" role="tab"
class={[ type="button"
"tab", tabindex="0"
if(@active_tab == :membership_fees, do: "tab-active", else: "!text-gray-800")
]}
aria-selected={@active_tab == :membership_fees} aria-selected={@active_tab == :membership_fees}
aria-controls="member-tabpanel-membership_fees"
class={[
"tab flex items-center gap-2",
if(@active_tab == :membership_fees, do: "tab-active", else: "text-base-content/70")
]}
phx-click="switch_tab" phx-click="switch_tab"
phx-value-tab="membership_fees" phx-value-tab="membership_fees"
> >
<.icon name="hero-credit-card" class="size-4 mr-2" /> <.icon name="hero-credit-card" class="size-4 shrink-0" />
{gettext("Membership Fees")} {gettext("Membership Fees")}
</button> </button>
</div> </div>
<%= if @active_tab == :contact do %> <%= if @active_tab == :contact do %>
<%!-- Contact Data Tab Content --%> <%!-- Contact Data Tab Content --%>
<%!-- Personal Data and Custom Fields Row --%> <div
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6"> id="member-tabpanel-contact"
<%!-- Personal Data Section --%> role="tabpanel"
<div> aria-labelledby="member-tab-contact"
<.section_box title={gettext("Personal Data")}> >
<div class="space-y-4"> <%!-- Personal Data and Custom Fields Row --%>
<%!-- Name Row --%> <div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
<div class="flex gap-6"> <%!-- Personal Data Section --%>
<.data_field <div>
label={gettext("First Name")} <.section_box title={gettext("Personal Data")}>
value={@member.first_name} <div class="space-y-4">
class="w-48" <%!-- Name Row --%>
/> <div class="flex gap-6">
<.data_field label={gettext("Last Name")} value={@member.last_name} class="w-48" /> <.data_field
</div> label={gettext("First Name")}
value={@member.first_name}
class="w-48"
/>
<.data_field
label={gettext("Last Name")}
value={@member.last_name}
class="w-48"
/>
</div>
<%!-- Address --%> <%!-- Address --%>
<div> <div>
<.data_field label={gettext("Address")} value={format_address(@member)} /> <.data_field label={gettext("Address")} value={format_address(@member)} />
</div> </div>
<%!-- Email --%> <%!-- Email --%>
<div> <div>
<.data_field label={gettext("Email")}> <.data_field label={gettext("Email")}>
<a <a
href={"mailto:#{MvWeb.MemberLive.Index.format_member_email(@member)}"} href={"mailto:#{MvWeb.MemberLive.Index.format_member_email(@member)}"}
class="text-blue-700 hover:text-blue-800 underline" class="text-blue-700 hover:text-blue-800 underline"
> >
{@member.email} {@member.email}
</a> </a>
</.data_field> </.data_field>
</div> </div>
<%!-- Membership Dates Row --%> <%!-- Membership Dates Row --%>
<div class="flex gap-6"> <div class="flex gap-6">
<.data_field <.data_field
label={gettext("Join Date")} label={gettext("Join Date")}
value={format_date(@member.join_date)} value={format_date(@member.join_date)}
class="w-28" class="w-28"
/> />
<.data_field <.data_field
label={gettext("Exit Date")} label={gettext("Exit Date")}
value={format_date(@member.exit_date)} value={format_date(@member.exit_date)}
class="w-28" class="w-28"
/> />
</div> </div>
<%!-- Linked User: only show when current user can see other users (e.g. admin). <%!-- Linked User: only show when current user can see other users (e.g. admin).
read_only cannot see linked user, so hide the section to avoid "No user linked" when read_only cannot see linked user, so hide the section to avoid "No user linked" when
a user is linked but not visible. --%> a user is linked but not visible. --%>
<%= if can_access_page?(@current_user, "/users") do %> <%= if can_access_page?(@current_user, "/users") do %>
<div>
<.data_field label={gettext("Linked User")}>
<%= if @member.user do %>
<.link
navigate={~p"/users/#{@member.user}"}
class="text-blue-700 hover:text-blue-800 underline inline-flex items-center gap-1"
>
<.icon name="hero-user" class="size-4" />
{@member.user.email}
</.link>
<% else %>
<span class="text-base-content/70 italic">
{gettext("No user linked")}
</span>
<% end %>
</.data_field>
</div>
<% end %>
<%!-- Groups (in Personal Data) --%>
<% groups = @member.groups || [] %>
<div> <div>
<.data_field label={gettext("Linked User")}> <.data_field label={gettext("Groups")}>
<%= if @member.user do %> <%= if Enum.empty?(groups) do %>
<.link <span class="text-base-content/70 italic">{gettext("No groups")}</span>
navigate={~p"/users/#{@member.user}"}
class="text-blue-700 hover:text-blue-800 underline inline-flex items-center gap-1"
>
<.icon name="hero-user" class="size-4" />
{@member.user.email}
</.link>
<% else %> <% else %>
<span class="text-base-content/70 italic">{gettext("No user linked")}</span> <div class="flex flex-wrap gap-2">
<%= for group <- groups do %>
<.button
variant="outline"
size="sm"
navigate={~p"/groups/#{group.slug}"}
aria-label={gettext("Member of group %{name}", name: group.name)}
>
{group.name}
</.button>
<% end %>
</div>
<% end %> <% end %>
</.data_field> </.data_field>
</div> </div>
<% end %>
<%!-- Groups (in Personal Data) --%> <%!-- Notes --%>
<% groups = @member.groups || [] %> <%= if @member.notes && String.trim(@member.notes) != "" do %>
<div> <div>
<.data_field label={gettext("Groups")}> <.data_field label={gettext("Notes")}>
<%= if Enum.empty?(groups) do %> <p class="whitespace-pre-wrap text-base-content/80">{@member.notes}</p>
<span class="text-base-content/70 italic">{gettext("No groups")}</span> </.data_field>
<% else %> </div>
<div class="flex flex-wrap gap-2">
<%= for group <- groups do %>
<.button
variant="outline"
size="sm"
navigate={~p"/groups/#{group.slug}"}
aria-label={gettext("Member of group %{name}", name: group.name)}
>
{group.name}
</.button>
<% end %>
</div>
<% end %>
</.data_field>
</div>
<%!-- Notes --%>
<%= if @member.notes && String.trim(@member.notes) != "" do %>
<div>
<.data_field label={gettext("Notes")}>
<p class="whitespace-pre-wrap text-base-content/80">{@member.notes}</p>
</.data_field>
</div>
<% end %>
</div>
</.section_box>
</div>
<%!-- Custom Fields Section --%>
<%= if Enum.any?(@custom_fields) do %>
<div>
<.section_box title={gettext("Custom Fields")}>
<div class="grid grid-cols-2 gap-4">
<%= for custom_field <- @custom_fields do %>
<% cfv = find_custom_field_value(@member.custom_field_values, custom_field.id) %>
<.data_field label={custom_field.name}>
{format_custom_field_value(cfv, custom_field.value_type)}
</.data_field>
<% end %> <% end %>
</div> </div>
</.section_box> </.section_box>
</div> </div>
<% end %>
</div>
<%!-- Payment Data Section --%> <%!-- Custom Fields Section --%>
<div class="w-full"> <%= if Enum.any?(@custom_fields) do %>
<.section_box title={gettext("Payment Data")}> <div>
<%= if @member.membership_fee_type do %> <.section_box title={gettext("Custom Fields")}>
<div class="flex gap-6 flex-wrap"> <div class="grid grid-cols-2 gap-4">
<.data_field <%= for custom_field <- @custom_fields do %>
label={gettext("Type")} <% cfv = find_custom_field_value(@member.custom_field_values, custom_field.id) %>
value={@member.membership_fee_type.name} <.data_field label={custom_field.name}>
class="min-w-32" {format_custom_field_value(cfv, custom_field.value_type)}
/> </.data_field>
<.data_field <% end %>
label={gettext("Membership Fee")} </div>
value={MembershipFeeHelpers.format_currency(@member.membership_fee_type.amount)} </.section_box>
class="min-w-24"
/>
<.data_field
label={gettext("Payment Interval")}
value={MembershipFeeHelpers.format_interval(@member.membership_fee_type.interval)}
class="min-w-32"
/>
<.data_field label={gettext("Last Cycle")} class="min-w-32">
<%= if @member.last_cycle_status do %>
<% status = @member.last_cycle_status %>
<span class={["badge", MembershipFeeHelpers.status_color(status)]}>
{format_status_label(status)}
</span>
<% else %>
<span class="badge badge-ghost">{gettext("No cycles")}</span>
<% end %>
</.data_field>
<.data_field label={gettext("Current Cycle")} class="min-w-36">
<%= if @member.current_cycle_status do %>
<% status = @member.current_cycle_status %>
<span class={["badge", MembershipFeeHelpers.status_color(status)]}>
{format_status_label(status)}
</span>
<% else %>
<span class="badge badge-ghost">{gettext("No cycles")}</span>
<% end %>
</.data_field>
</div>
<% else %>
<div class="text-base-content/70 italic">
{gettext("No membership fee type assigned")}
</div> </div>
<% end %> <% end %>
</.section_box> </div>
<%!-- Payment Data Section --%>
<div class="w-full">
<.section_box title={gettext("Payment Data")}>
<%= if @member.membership_fee_type do %>
<div class="flex gap-6 flex-wrap">
<.data_field
label={gettext("Type")}
value={@member.membership_fee_type.name}
class="min-w-32"
/>
<.data_field
label={gettext("Membership Fee")}
value={MembershipFeeHelpers.format_currency(@member.membership_fee_type.amount)}
class="min-w-24"
/>
<.data_field
label={gettext("Payment Interval")}
value={
MembershipFeeHelpers.format_interval(@member.membership_fee_type.interval)
}
class="min-w-32"
/>
<.data_field label={gettext("Last Cycle")} class="min-w-32">
<%= if @member.last_cycle_status do %>
<% status = @member.last_cycle_status %>
<span class={["badge", MembershipFeeHelpers.status_color(status)]}>
{format_status_label(status)}
</span>
<% else %>
<span class="badge badge-ghost">{gettext("No cycles")}</span>
<% end %>
</.data_field>
<.data_field label={gettext("Current Cycle")} class="min-w-36">
<%= if @member.current_cycle_status do %>
<% status = @member.current_cycle_status %>
<span class={["badge", MembershipFeeHelpers.status_color(status)]}>
{format_status_label(status)}
</span>
<% else %>
<span class="badge badge-ghost">{gettext("No cycles")}</span>
<% end %>
</.data_field>
</div>
<% else %>
<div class="text-base-content/70 italic">
{gettext("No membership fee type assigned")}
</div>
<% end %>
</.section_box>
</div>
</div> </div>
<% end %> <% end %>
<%= if @active_tab == :membership_fees do %> <%= if @active_tab == :membership_fees do %>
<%!-- Membership Fees Tab Content --%> <%!-- Membership Fees Tab Content --%>
<.live_component <div
module={MvWeb.MemberLive.Show.MembershipFeesComponent} id="member-tabpanel-membership_fees"
id={"membership-fees-#{@member.id}"} role="tabpanel"
member={@member} aria-labelledby="member-tab-membership_fees"
current_user={@current_user} >
vereinfacht_receipts={@vereinfacht_receipts} <.live_component
/> module={MvWeb.MemberLive.Show.MembershipFeesComponent}
id={"membership-fees-#{@member.id}"}
member={@member}
current_user={@current_user}
vereinfacht_receipts={@vereinfacht_receipts}
/>
</div>
<% end %>
<%!-- Danger zone: same section pattern as section_box (h2 outside border) --%>
<%= if can?(@current_user, :destroy, @member) do %>
<section class="mt-8 mb-6" aria-labelledby="danger-zone-heading">
<h2 id="danger-zone-heading" class="text-lg font-semibold mb-3 text-error">
{gettext("Danger zone")}
</h2>
<div class="border border-base-300 rounded-lg p-4 bg-base-100">
<p class="text-base-content/70 mb-4">
{gettext(
"Deleting this member cannot be undone. All related data (e.g. membership fee cycles) will be removed."
)}
</p>
<.button
variant="danger"
phx-click="delete"
phx-value-id={@member.id}
data-confirm={
gettext("Are you sure you want to delete %{name}? This action cannot be undone.",
name: MvWeb.Helpers.MemberHelpers.display_name(@member)
)
}
data-testid="member-delete"
aria-label={
gettext("Delete member %{name}",
name: MvWeb.Helpers.MemberHelpers.display_name(@member)
)
}
>
<.icon name="hero-trash" class="size-4" />
{gettext("Delete member")}
</.button>
</div>
</section>
<% end %> <% end %>
</div> </div>
</Layouts.app> </Layouts.app>
@ -328,6 +394,35 @@ defmodule MvWeb.MemberLive.Show do
{:noreply, assign(socket, :active_tab, :membership_fees)} {:noreply, assign(socket, :active_tab, :membership_fees)}
end end
@impl true
def handle_event("delete", %{"id" => id}, socket) do
member = socket.assigns.member
actor = current_actor(socket)
if to_string(id) != to_string(member.id) do
{:noreply, put_flash(socket, :error, gettext("Member not found"))}
else
case Ash.destroy(member, actor: actor) do
:ok ->
{:noreply,
socket
|> put_flash(:success, gettext("Member deleted successfully"))
|> push_navigate(to: ~p"/members")}
{:error, %Ash.Error.Forbidden{}} ->
{:noreply,
put_flash(
socket,
:error,
gettext("You do not have permission to delete this member")
)}
{:error, error} ->
{:noreply, put_flash(socket, :error, format_error(error))}
end
end
end
def handle_event("load_vereinfacht_receipts", %{"contact_id" => contact_id}, socket) do def handle_event("load_vereinfacht_receipts", %{"contact_id" => contact_id}, socket) do
response = response =
case Mv.Vereinfacht.Client.get_contact_with_receipts(contact_id) do case Mv.Vereinfacht.Client.get_contact_with_receipts(contact_id) do
@ -358,6 +453,19 @@ defmodule MvWeb.MemberLive.Show do
defp page_title(:show), do: gettext("Show Member") defp page_title(:show), do: gettext("Show Member")
defp page_title(:edit), do: gettext("Edit Member") defp page_title(:edit), do: gettext("Edit Member")
defp format_error(%Ash.Error.Invalid{errors: errors}) do
error_messages =
Enum.map(errors, fn
%{field: field, message: message} -> "#{field}: #{message}"
%{message: message} -> message
_ -> inspect(errors)
end)
Enum.join(error_messages, ", ")
end
defp format_error(error), do: inspect(error)
# ----------------------------------------------------------------- # -----------------------------------------------------------------
# Helper Components # Helper Components
# ----------------------------------------------------------------- # -----------------------------------------------------------------

View file

@ -3,11 +3,38 @@ defmodule MvWeb.MemberLive.FormErrorHandlingTest do
Tests for error handling in the member form, specifically flash message display. Tests for error handling in the member form, specifically flash message display.
""" """
use MvWeb.ConnCase, async: false use MvWeb.ConnCase, async: false
use Gettext, backend: MvWeb.Gettext
import Phoenix.LiveViewTest import Phoenix.LiveViewTest
require Ash.Query require Ash.Query
describe "tab visibility" do
@tag :ui
test "Payments tab is not visible on new member form", %{conn: conn} do
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, "/members/new")
refute html =~ gettext("Payments")
end
@tag :ui
test "Payments tab is not visible on edit member form", %{conn: conn} do
system_actor = Mv.Helpers.SystemActor.get_system_actor()
{:ok, member} =
Mv.Membership.create_member(
%{first_name: "Edit", last_name: "Member", email: "edit@example.com"},
actor: system_actor
)
conn = conn_with_oidc_user(conn)
{:ok, _view, html} = live(conn, ~p"/members/#{member}/edit")
refute html =~ gettext("Payments")
end
end
describe "error handling - flash messages" do describe "error handling - flash messages" do
setup do setup do
{:ok, settings} = Mv.Membership.get_settings() {:ok, settings} = Mv.Membership.get_settings()

View file

@ -266,36 +266,42 @@ defmodule MvWeb.MemberLive.IndexTest do
assert is_list(state.socket.assigns.members) assert is_list(state.socket.assigns.members)
end end
test "can delete a member without error", %{conn: conn} do @tag :ui
test "member index does not render Edit or Delete actions", %{conn: conn} do
system_actor = Mv.Helpers.SystemActor.get_system_actor() system_actor = Mv.Helpers.SystemActor.get_system_actor()
# Create a test member first {:ok, _member} =
{:ok, member} =
Mv.Membership.create_member( Mv.Membership.create_member(
%{ %{first_name: "Test", last_name: "User", email: "test@example.com"},
first_name: "Test",
last_name: "User",
email: "test@example.com"
},
actor: system_actor actor: system_actor
) )
conn = conn_with_oidc_user(conn) conn = conn_with_oidc_user(conn)
{:ok, index_view, _html} = live(conn, "/members") {:ok, view, html} = live(conn, "/members")
# Verify the member is displayed refute has_element?(view, "[data-testid='member-edit']")
assert has_element?(index_view, "#members", "Test User") refute html =~ ~s(data-testid="member-delete")
end
# Click the delete link for this member @tag :ui
index_view test "row click navigates to member show", %{conn: conn} do
|> element("a", "Delete") system_actor = Mv.Helpers.SystemActor.get_system_actor()
{:ok, member} =
Mv.Membership.create_member(
%{first_name: "Row", last_name: "Click", email: "rowclick@example.com"},
actor: system_actor
)
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, "/members")
# Click a data cell (e.g. second column = first name) to trigger row navigation
view
|> element("#row-#{member.id} td:nth-child(2)")
|> render_click() |> render_click()
# Verify the member is no longer displayed assert_redirect(view, ~p"/members/#{member}")
refute has_element?(index_view, "#members", "Test User")
# Verify the member was actually deleted from the database
assert not (Mv.Membership.Member |> Ash.Query.filter(id == ^member.id) |> Ash.exists?())
end end
describe "copy_emails feature" do describe "copy_emails feature" do

View file

@ -134,6 +134,35 @@ defmodule MvWeb.MemberLive.ShowTest do
end end
end end
describe "delete action" do
test "renders Delete button when user can destroy member", %{
conn: conn,
member: member
} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, ~p"/members/#{member}")
assert has_element?(view, "[data-testid='member-delete']")
end
test "delete event removes member and redirects to index", %{
conn: conn,
member: member
} do
conn = conn_with_oidc_user(conn)
{:ok, view, _html} = live(conn, ~p"/members/#{member}")
view
|> render_click("delete", %{"id" => member.id})
assert_redirect(view, ~p"/members")
refute Mv.Membership.Member
|> Ash.Query.filter(id == ^member.id)
|> Ash.exists?()
end
end
describe "custom field value formatting" do describe "custom field value formatting" do
test "formats string custom field values", %{conn: conn, member: member, actor: actor} do test "formats string custom field values", %{conn: conn, member: member, actor: actor} do
{:ok, custom_field} = {:ok, custom_field} =