Adds more consistency in various UX topics closes #447 #448

Merged
carla merged 9 commits from feat/447_concistency into main 2026-02-25 17:34:12 +01:00
8 changed files with 361 additions and 276 deletions
Showing only changes of commit 02af136fd9 - Show all commits

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,38 +54,54 @@ 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 --%>
<div
id="member-tabpanel-contact"
role="tabpanel"
aria-labelledby="member-tab-contact"
>
<%!-- Personal Data and Custom Fields Row --%> <%!-- Personal Data and Custom Fields Row --%>
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6"> <div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
<%!-- Personal Data Section --%> <%!-- Personal Data Section --%>
@ -99,7 +115,11 @@ defmodule MvWeb.MemberLive.Show do
value={@member.first_name} value={@member.first_name}
class="w-48" class="w-48"
/> />
<.data_field label={gettext("Last Name")} value={@member.last_name} class="w-48" /> <.data_field
label={gettext("Last Name")}
value={@member.last_name}
class="w-48"
/>
</div> </div>
<%!-- Address --%> <%!-- Address --%>
@ -148,7 +168,9 @@ defmodule MvWeb.MemberLive.Show do
{@member.user.email} {@member.user.email}
</.link> </.link>
<% else %> <% else %>
<span class="text-base-content/70 italic">{gettext("No user linked")}</span> <span class="text-base-content/70 italic">
{gettext("No user linked")}
</span>
<% end %> <% end %>
</.data_field> </.data_field>
</div> </div>
@ -223,7 +245,9 @@ defmodule MvWeb.MemberLive.Show do
/> />
<.data_field <.data_field
label={gettext("Payment Interval")} label={gettext("Payment Interval")}
value={MembershipFeeHelpers.format_interval(@member.membership_fee_type.interval)} value={
MembershipFeeHelpers.format_interval(@member.membership_fee_type.interval)
}
class="min-w-32" class="min-w-32"
/> />
<.data_field label={gettext("Last Cycle")} class="min-w-32"> <.data_field label={gettext("Last Cycle")} class="min-w-32">
@ -254,10 +278,16 @@ defmodule MvWeb.MemberLive.Show do
<% end %> <% end %>
</.section_box> </.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 --%>
<div
id="member-tabpanel-membership_fees"
role="tabpanel"
aria-labelledby="member-tab-membership_fees"
>
<.live_component <.live_component
module={MvWeb.MemberLive.Show.MembershipFeesComponent} module={MvWeb.MemberLive.Show.MembershipFeesComponent}
id={"membership-fees-#{@member.id}"} id={"membership-fees-#{@member.id}"}
@ -265,6 +295,42 @@ defmodule MvWeb.MemberLive.Show do
current_user={@current_user} current_user={@current_user}
vereinfacht_receipts={@vereinfacht_receipts} 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} =