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)
defp boolean_filter_label(_boolean_custom_fields, boolean_filters)
when map_size(boolean_filters) == 0 do
gettext("All")
gettext("Apply filters")
end
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
- Edit existing member details
- Grouped sections for better organization
- Tab navigation (Payments tab disabled, coming soon)
- Manage custom properties (dynamic fields, displayed sorted by name)
- Real-time validation with visual feedback
@ -56,23 +55,12 @@ defmodule MvWeb.MemberLive.Form do
</.header>
<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">
<button type="button" role="tab" class="tab tab-active" aria-selected="true">
<.icon name="hero-identification" class="size-4 mr-2" />
{gettext("Contact Data")}
</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>
<%!-- Personal Data and Custom Fields Row --%>

View file

@ -15,7 +15,6 @@ defmodule MvWeb.MemberLive.Index do
- `sort_order` - Sort direction (:asc or :desc)
## Events
- `delete` - Remove a member from the database
- `select_member` - Toggle individual member selection
- `select_all` - Toggle selection of all visible members
- `copy_emails` - Copy email addresses of selected members to clipboard
@ -157,50 +156,10 @@ defmodule MvWeb.MemberLive.Index do
Handles member-related UI events.
## Supported events:
- `"delete"` - Removes a member from the database
- `"select_member"` - Toggles individual member selection
- `"select_all"` - Toggles selection of all visible members
- `"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
def handle_event("select_member", %{"id" => id}, socket) do
selected =
@ -343,22 +302,6 @@ defmodule MvWeb.MemberLive.Index do
{:noreply, push_patch(socket, to: ~p"/members?#{query_params}", replace: true)}
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
# -----------------------------------------------------------------

View file

@ -379,26 +379,10 @@
</:col>
<:action :let={member}>
<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>
<%= 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>
</.table>
</Layouts.app>

View file

@ -54,217 +54,283 @@ defmodule MvWeb.MemberLive.Show do
</.header>
<div class="mt-6 space-y-6">
<%!-- Tab Navigation --%>
<div role="tablist" class="tabs tabs-bordered">
<%!-- Tab Navigation: surface only behind buttons (inline); tabs-bordered; tabs-lg; both tabs keyboard-focusable --%>
<div
role="tablist"
class="tabs tabs-bordered tabs-lg bg-base-200/60 rounded-box p-1 w-fit"
>
<button
id="member-tab-contact"
role="tab"
class={[
"tab",
if(@active_tab == :contact, do: "tab-active", else: "!text-gray-800")
]}
type="button"
tabindex="0"
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-value-tab="contact"
>
<.icon name="hero-identification" class="size-4 mr-2" />
<.icon name="hero-identification" class="size-4 shrink-0" />
{gettext("Contact Data")}
</button>
<button
id="member-tab-membership_fees"
role="tab"
class={[
"tab",
if(@active_tab == :membership_fees, do: "tab-active", else: "!text-gray-800")
]}
type="button"
tabindex="0"
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-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")}
</button>
</div>
<%= if @active_tab == :contact do %>
<%!-- Contact Data Tab Content --%>
<%!-- Personal Data and Custom Fields Row --%>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
<%!-- Personal Data Section --%>
<div>
<.section_box title={gettext("Personal Data")}>
<div class="space-y-4">
<%!-- Name Row --%>
<div class="flex gap-6">
<.data_field
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>
<div
id="member-tabpanel-contact"
role="tabpanel"
aria-labelledby="member-tab-contact"
>
<%!-- Personal Data and Custom Fields Row --%>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
<%!-- Personal Data Section --%>
<div>
<.section_box title={gettext("Personal Data")}>
<div class="space-y-4">
<%!-- Name Row --%>
<div class="flex gap-6">
<.data_field
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 --%>
<div>
<.data_field label={gettext("Address")} value={format_address(@member)} />
</div>
<%!-- Address --%>
<div>
<.data_field label={gettext("Address")} value={format_address(@member)} />
</div>
<%!-- Email --%>
<div>
<.data_field label={gettext("Email")}>
<a
href={"mailto:#{MvWeb.MemberLive.Index.format_member_email(@member)}"}
class="text-blue-700 hover:text-blue-800 underline"
>
{@member.email}
</a>
</.data_field>
</div>
<%!-- Email --%>
<div>
<.data_field label={gettext("Email")}>
<a
href={"mailto:#{MvWeb.MemberLive.Index.format_member_email(@member)}"}
class="text-blue-700 hover:text-blue-800 underline"
>
{@member.email}
</a>
</.data_field>
</div>
<%!-- Membership Dates Row --%>
<div class="flex gap-6">
<.data_field
label={gettext("Join Date")}
value={format_date(@member.join_date)}
class="w-28"
/>
<.data_field
label={gettext("Exit Date")}
value={format_date(@member.exit_date)}
class="w-28"
/>
</div>
<%!-- Membership Dates Row --%>
<div class="flex gap-6">
<.data_field
label={gettext("Join Date")}
value={format_date(@member.join_date)}
class="w-28"
/>
<.data_field
label={gettext("Exit Date")}
value={format_date(@member.exit_date)}
class="w-28"
/>
</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
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>
<.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>
<.data_field label={gettext("Groups")}>
<%= if Enum.empty?(groups) do %>
<span class="text-base-content/70 italic">{gettext("No groups")}</span>
<% 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 %>
</.data_field>
</div>
<% end %>
<%!-- Groups (in Personal Data) --%>
<% groups = @member.groups || [] %>
<div>
<.data_field label={gettext("Groups")}>
<%= if Enum.empty?(groups) do %>
<span class="text-base-content/70 italic">{gettext("No groups")}</span>
<% else %>
<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>
<%!-- 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>
<% end %>
</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")}
<%!-- 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 %>
</div>
</.section_box>
</div>
<% 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>
<% end %>
<%= if @active_tab == :membership_fees do %>
<%!-- Membership Fees Tab Content --%>
<.live_component
module={MvWeb.MemberLive.Show.MembershipFeesComponent}
id={"membership-fees-#{@member.id}"}
member={@member}
current_user={@current_user}
vereinfacht_receipts={@vereinfacht_receipts}
/>
<div
id="member-tabpanel-membership_fees"
role="tabpanel"
aria-labelledby="member-tab-membership_fees"
>
<.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 %>
</div>
</Layouts.app>
@ -328,6 +394,35 @@ defmodule MvWeb.MemberLive.Show do
{:noreply, assign(socket, :active_tab, :membership_fees)}
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
response =
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(: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
# -----------------------------------------------------------------

View file

@ -3,11 +3,38 @@ defmodule MvWeb.MemberLive.FormErrorHandlingTest do
Tests for error handling in the member form, specifically flash message display.
"""
use MvWeb.ConnCase, async: false
use Gettext, backend: MvWeb.Gettext
import Phoenix.LiveViewTest
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
setup do
{:ok, settings} = Mv.Membership.get_settings()

View file

@ -266,36 +266,42 @@ defmodule MvWeb.MemberLive.IndexTest do
assert is_list(state.socket.assigns.members)
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()
# Create a test member first
{:ok, member} =
{:ok, _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
)
conn = conn_with_oidc_user(conn)
{:ok, index_view, _html} = live(conn, "/members")
{:ok, view, html} = live(conn, "/members")
# Verify the member is displayed
assert has_element?(index_view, "#members", "Test User")
refute has_element?(view, "[data-testid='member-edit']")
refute html =~ ~s(data-testid="member-delete")
end
# Click the delete link for this member
index_view
|> element("a", "Delete")
@tag :ui
test "row click navigates to member show", %{conn: conn} do
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()
# Verify the member is no longer displayed
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?())
assert_redirect(view, ~p"/members/#{member}")
end
describe "copy_emails feature" do

View file

@ -134,6 +134,35 @@ defmodule MvWeb.MemberLive.ShowTest do
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
test "formats string custom field values", %{conn: conn, member: member, actor: actor} do
{:ok, custom_field} =