feat(member): deactivate and reactivate members via an exit-date dialog

This commit is contained in:
Moritz 2026-06-08 12:17:02 +02:00
parent bcab2e21c4
commit 3dc3a2b8ef
6 changed files with 1735 additions and 1326 deletions

View file

@ -329,6 +329,14 @@ defmodule MvWeb.MemberLive.Show do
</div> </div>
<% end %> <% end %>
<%!-- Deactivate/reactivate sub-flow (gated on :update, owns its own modal) --%>
<.live_component
module={MvWeb.MemberLive.Show.DeactivateComponent}
id="member-deactivate"
member={@member}
current_user={@current_user}
/>
<%!-- Danger zone: same section pattern as section_box (h2 outside border) --%> <%!-- Danger zone: same section pattern as section_box (h2 outside border) --%>
<%= if can?(@current_user, :destroy, @member) do %> <%= if can?(@current_user, :destroy, @member) do %>
<section class="mt-8 mb-6" aria-labelledby="danger-zone-heading"> <section class="mt-8 mb-6" aria-labelledby="danger-zone-heading">

View file

@ -0,0 +1,203 @@
defmodule MvWeb.MemberLive.Show.DeactivateComponent do
@moduledoc """
LiveComponent owning the member deactivate/reactivate sub-flow on the member show page.
## Features
- Deactivate control (shown when the member has no exit_date)
- Reactivate control (shown when the member has an exit_date)
- Date-selection modal (default: today, future dates allowed) for deactivation
- Routes both actions through `Membership.update_member/2` with the real user actor,
inheriting the `exit_date > join_date` validation and the cycle-regeneration hook.
Controls are gated on `:update` permission for the member. On success the component
notifies the parent with `{:member_updated, member}`, which the parent already handles.
"""
use MvWeb, :live_component
import MvWeb.Authorization, only: [can?: 3]
alias Mv.Membership
alias MvWeb.Helpers.MemberHelpers
@impl true
def render(assigns) do
~H"""
<div id={@id}>
<%= if @can_update do %>
<section class="mt-8 mb-6" aria-labelledby="membership-state-heading">
<h2 id="membership-state-heading" class="text-lg font-semibold mb-3">
{gettext("Membership status")}
</h2>
<div class="border border-base-300 rounded-lg p-4 bg-base-100">
<%= if @member.exit_date do %>
<p class="text-base-content/70 mb-4">
{gettext(
"This member is deactivated (exit date set). Reactivating clears the exit date."
)}
</p>
<.button
id="reactivate-member-trigger"
data-testid="member-reactivate"
variant="primary"
phx-click="reactivate"
phx-target={@myself}
aria-label={
gettext("Reactivate member %{name}", name: MemberHelpers.display_name(@member))
}
>
<.icon name="hero-arrow-uturn-left" class="size-4" />
{gettext("Reactivate member")}
</.button>
<% else %>
<p class="text-base-content/70 mb-4">
{gettext(
"Deactivating this member records an exit date. You can reactivate them later."
)}
</p>
<.button
id="deactivate-member-trigger"
data-testid="member-deactivate"
variant="outline"
phx-click="open_modal"
phx-target={@myself}
aria-label={
gettext("Deactivate member %{name}", name: MemberHelpers.display_name(@member))
}
>
<.icon name="hero-arrow-right-on-rectangle" class="size-4" />
{gettext("Deactivate member")}
</.button>
<% end %>
</div>
</section>
<% end %>
<%= if @show_modal do %>
<dialog
id="deactivate-member-modal"
class="modal modal-open"
role="dialog"
aria-labelledby="deactivate-member-modal-title"
phx-keydown="dialog_keydown"
phx-target={@myself}
>
<div class="modal-box">
<h3 id="deactivate-member-modal-title" class="text-lg font-bold">
{gettext("When did this member leave?")}
</h3>
<form phx-submit="deactivate" phx-target={@myself}>
<.input
type="date"
id="deactivate-exit-date"
name="exit_date"
label={gettext("Exit date")}
value={@exit_date}
errors={if @error, do: [@error], else: []}
required
phx-mounted={JS.focus()}
/>
<div class="modal-action">
<.button
type="button"
variant="neutral"
phx-click="cancel_modal"
phx-target={@myself}
>
{gettext("Cancel")}
</.button>
<.button type="submit" variant="primary">{gettext("Deactivate")}</.button>
</div>
</form>
</div>
</dialog>
<% end %>
</div>
"""
end
@impl true
def update(assigns, socket) do
{:ok,
socket
|> assign(assigns)
|> assign(:can_update, can?(assigns.current_user, :update, assigns.member))
|> assign_new(:show_modal, fn -> false end)
|> assign_new(:exit_date, fn -> Date.utc_today() end)
|> assign_new(:error, fn -> nil end)}
end
@impl true
def handle_event("open_modal", _params, socket) do
{:noreply,
socket
|> assign(:show_modal, true)
|> assign(:exit_date, Date.utc_today())
|> assign(:error, nil)}
end
def handle_event("cancel_modal", _params, socket) do
{:noreply, close_modal(socket)}
end
def handle_event("dialog_keydown", %{"key" => key}, socket) when key in ["Escape", "Esc"] do
{:noreply, close_modal(socket)}
end
def handle_event("dialog_keydown", _params, socket), do: {:noreply, socket}
def handle_event("deactivate", %{"exit_date" => exit_date_str}, socket) do
case Date.from_iso8601(exit_date_str) do
{:ok, exit_date} ->
apply_exit_date(socket, exit_date, show_inline_error: true)
{:error, _reason} ->
{:noreply, assign(socket, :error, gettext("Invalid date format"))}
end
end
def handle_event("reactivate", _params, socket) do
apply_exit_date(socket, nil, show_inline_error: false)
end
defp apply_exit_date(socket, exit_date, opts) do
member = socket.assigns.member
actor = socket.assigns.current_user
case Membership.update_member(member, %{exit_date: exit_date}, actor: actor) do
{:ok, updated_member} ->
send(self(), {:member_updated, updated_member})
{:noreply,
socket
|> assign(:member, updated_member)
|> close_modal()}
{:error, error} ->
if opts[:show_inline_error] do
{:noreply, assign(socket, :error, format_error(error))}
else
send(self(), {:put_flash, :error, format_error(error)})
{:noreply, socket}
end
end
end
defp close_modal(socket) do
socket
|> assign(:show_modal, false)
|> assign(:error, nil)
end
defp format_error(%Ash.Error.Invalid{errors: errors}) do
Enum.map_join(errors, ", ", fn
%{message: message} -> message
other -> inspect(other)
end)
end
defp format_error(%Ash.Error.Forbidden{}) do
gettext("You are not allowed to perform this action.")
end
defp format_error(_error), do: gettext("An error occurred")
end

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,108 @@
defmodule MvWeb.MemberLive.DeactivateTest do
@moduledoc """
Tests for the member deactivate/reactivate sub-flow on the member show page,
driven through the parent LiveView (the DeactivateComponent is stateful).
"""
use MvWeb.ConnCase, async: true
import Phoenix.LiveViewTest
use Gettext, backend: MvWeb.Gettext
alias Mv.Fixtures
defp reload_member(member) do
Ash.get!(Mv.Membership.Member, member.id, actor: Mv.Helpers.SystemActor.get_system_actor())
end
describe "deactivate/reactivate control visibility (§1.6, §1.8)" do
@tag role: :admin
test "shows Deactivate and hides Reactivate when member has no exit_date", %{conn: conn} do
member = Fixtures.member_fixture()
{:ok, view, _html} = live(conn, ~p"/members/#{member.id}")
assert has_element?(view, "[data-testid=member-deactivate]")
refute has_element?(view, "[data-testid=member-reactivate]")
end
@tag role: :admin
test "shows Reactivate and hides Deactivate when member has an exit_date", %{conn: conn} do
member = Fixtures.member_fixture(%{exit_date: Date.utc_today()})
{:ok, view, _html} = live(conn, ~p"/members/#{member.id}")
assert has_element?(view, "[data-testid=member-reactivate]")
refute has_element?(view, "[data-testid=member-deactivate]")
end
@tag role: :read_only
test "hides the deactivate/reactivate control for a user without :update permission", %{
conn: conn
} do
member = Fixtures.member_fixture()
{:ok, view, _html} = live(conn, ~p"/members/#{member.id}")
refute has_element?(view, "[data-testid=member-deactivate]")
refute has_element?(view, "[data-testid=member-reactivate]")
end
end
describe "deactivate modal (§1.3)" do
@tag role: :admin
test "opening the deactivate modal prefills the date input with today", %{conn: conn} do
member = Fixtures.member_fixture()
{:ok, view, _html} = live(conn, ~p"/members/#{member.id}")
view
|> element("[data-testid=member-deactivate]")
|> render_click()
assert has_element?(
view,
~s(#deactivate-exit-date[value="#{Date.to_iso8601(Date.utc_today())}"])
)
end
end
describe "submitting the deactivate modal (§1.2)" do
@tag role: :admin
test "submitting the deactivate modal with a future date sets exit_date", %{conn: conn} do
member = Fixtures.member_fixture(%{join_date: Date.utc_today()})
future_date = Date.add(Date.utc_today(), 30)
{:ok, view, _html} = live(conn, ~p"/members/#{member.id}")
view
|> element("[data-testid=member-deactivate]")
|> render_click()
view
|> element("#deactivate-member-modal form")
|> render_submit(%{"exit_date" => Date.to_iso8601(future_date)})
assert reload_member(member).exit_date == future_date
# UI flips to offering Reactivate
assert has_element?(view, "[data-testid=member-reactivate]")
end
end
describe "reactivate (§1.7)" do
@tag role: :admin
test "reactivating a member clears exit_date", %{conn: conn} do
member = Fixtures.member_fixture(%{exit_date: Date.utc_today()})
{:ok, view, _html} = live(conn, ~p"/members/#{member.id}")
view
|> element("[data-testid=member-reactivate]")
|> render_click()
assert reload_member(member).exit_date == nil
# UI flips back to offering Deactivate
assert has_element?(view, "[data-testid=member-deactivate]")
end
end
end